diff --git a/src/controller.cpp b/src/controller.cpp index f1df75a..1a93c99 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -633,6 +633,52 @@ void RcxController::refresh() { } } + // Update value history and compute heat levels + // Use the snapshot provider if active; skip entirely if no valid provider + { + const Provider* prov = nullptr; + if (m_snapshotProv) + prov = m_snapshotProv.get(); + else if (m_doc->provider && m_doc->provider->isValid()) + prov = m_doc->provider.get(); + + if (prov) { + for (auto& lm : m_lastResult.meta) { + if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue; + if (isSyntheticLine(lm) || lm.isContinuation) continue; + if (lm.lineKind != LineKind::Field) continue; + + 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); + int sz = node.byteSize(); + if (sz <= 0 || !prov->isReadable(addr, sz)) continue; + + QString val = fmt::readValue(node, *prov, addr, lm.subLine); + if (!val.isEmpty()) { + m_valueHistory[lm.nodeId].record(val); + lm.heatLevel = m_valueHistory[lm.nodeId].heatLevel(); + } + } + } + + // 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) QSet valid; for (uint64_t id : m_selIds) { @@ -656,6 +702,7 @@ void RcxController::refresh() { for (auto* editor : m_editors) { editor->setCustomTypeNames(customTypes); + editor->setValueHistoryRef(&m_valueHistory); ViewState vs = editor->saveViewState(); editor->applyDocument(m_lastResult); editor->restoreViewState(vs); @@ -914,11 +961,13 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { int ai = tree.indexOfId(adj.nodeId); if (ai >= 0) tree.nodes[ai].offset = adj.newOffset; } - // Remove nodes + // Remove nodes and their value history QVector indices = tree.subtreeIndices(c.nodeId); std::sort(indices.begin(), indices.end(), std::greater()); - for (int idx : indices) + for (int idx : indices) { + m_valueHistory.remove(tree.nodes[idx].id); tree.nodes.remove(idx); + } tree.invalidateIdCache(); } } else if constexpr (std::is_same_v) { @@ -932,11 +981,14 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { resetSnapshot(); } else if constexpr (std::is_same_v) { const QByteArray& bytes = isUndo ? c.oldBytes : c.newBytes; - if (!m_doc->provider->writeBytes(c.addr, bytes)) + // Write through snapshot (patches pages only on success) or provider directly. + // If write fails, the snapshot is NOT patched, so the next compose shows the + // real unchanged value — no optimistic visual leak. + bool ok = m_snapshotProv + ? m_snapshotProv->write(c.addr, bytes.constData(), bytes.size()) + : m_doc->provider->writeBytes(c.addr, bytes); + if (!ok) qWarning() << "WriteBytes failed at address" << QString::number(c.addr, 16); - // Patch snapshot so compose sees the new value immediately - if (m_snapshotProv) - m_snapshotProv->patchPages(c.addr, bytes.constData(), bytes.size()); } else if constexpr (std::is_same_v) { int idx = tree.indexOfId(c.nodeId); if (idx >= 0) { @@ -1019,8 +1071,21 @@ void RcxController::setNodeValue(int nodeIdx, int subLine, const QString& text, // Validate write range before pushing command if (!m_doc->provider->isReadable(addr, writeSize)) return; + // Read old bytes before writing (for undo) QByteArray oldBytes = m_doc->provider->readBytes(addr, writeSize); + // Test the write first — don't push a command that will silently fail. + // This prevents optimistic visual updates for read-only providers. + bool writeOk = m_snapshotProv + ? m_snapshotProv->write(addr, newBytes.constData(), newBytes.size()) + : m_doc->provider->writeBytes(addr, newBytes); + if (!writeOk) { + qWarning() << "Write failed at address" << QString::number(addr, 16); + refresh(); // refresh to show the real unchanged value + return; + } + + // Write succeeded — push undo command (redo will write again, which is harmless) m_doc->undoStack.push(new RcxCommand(this, cmd::WriteBytes{addr, oldBytes, newBytes})); } @@ -2194,6 +2259,7 @@ void RcxController::resetSnapshot() { m_snapshotProv.reset(); m_prevPages.clear(); m_changedOffsets.clear(); + m_valueHistory.clear(); } void RcxController::handleMarginClick(RcxEditor* editor, int margin, diff --git a/src/controller.h b/src/controller.h index 865f09b..06d8a18 100644 --- a/src/controller.h +++ b/src/controller.h @@ -148,6 +148,7 @@ private: std::unique_ptr m_snapshotProv; PageMap m_prevPages; QSet m_changedOffsets; + QHash m_valueHistory; uint64_t m_refreshGen = 0; uint64_t m_readGen = 0; bool m_readInFlight = false; diff --git a/src/core.h b/src/core.h index 9bd37ed..41f8316 100644 --- a/src/core.h +++ b/src/core.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -405,6 +406,49 @@ struct NodeTree { }; +// ── Value History (ring buffer for heatmap) ── + +struct ValueHistory { + static constexpr int kCapacity = 10; + std::array values; + int count = 0; // total unique values recorded + int head = 0; // next write position in ring + + void record(const QString& v) { + if (count > 0) { + int last = (head + kCapacity - 1) % kCapacity; + if (values[last] == v) return; // no change + } + values[head] = v; + head = (head + 1) % kCapacity; + if (count < INT_MAX) count++; + } + + int uniqueCount() const { return qMin(count, kCapacity); } + + // 0=static, 1=cold(2 unique), 2=warm(3-4), 3=hot(5+) + int heatLevel() const { + if (count <= 1) return 0; + if (count == 2) return 1; + if (count <= 4) return 2; + return 3; + } + + QString last() const { + if (count == 0) return {}; + return values[(head + kCapacity - 1) % kCapacity]; + } + + // Iterate from oldest to newest (up to uniqueCount entries) + template + void forEach(Fn&& fn) const { + int n = uniqueCount(); + int start = (head + kCapacity - n) % kCapacity; + for (int i = 0; i < n; i++) + fn(values[(start + i) % kCapacity]); + } +}; + // ── LineMeta ── enum class LineKind : uint8_t { @@ -439,6 +483,7 @@ struct LineMeta { uint64_t offsetAddr = 0; // Raw absolute address (for margin toggle) uint32_t markerMask = 0; bool dataChanged = false; // true if any byte in this node changed since last refresh + int heatLevel = 0; // 0=static, 1=cold, 2=warm, 3=hot (from ValueHistory) QVector changedByteIndices; // Hex preview: which byte indices (0-based) changed on this line int lineByteCount = 0; // Hex preview: actual data byte count on this line int effectiveTypeW = 14; // Per-line type column width used for rendering diff --git a/src/editor.cpp b/src/editor.cpp index af50b0a..3d4345c 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -24,10 +24,12 @@ static constexpr int IND_HEX_DIM = 9; static constexpr int IND_BASE_ADDR = 10; // Default text color override for command row address static constexpr int IND_HOVER_SPAN = 11; // Blue text on hover (link-like) static constexpr int IND_CMD_PILL = 12; // Rounded chip behind command row spans -static constexpr int IND_DATA_CHANGED = 13; // Amber text for changed data values +static constexpr int IND_HEAT_COLD = 13; // Heatmap level 1 (changed once) static constexpr int IND_CLASS_NAME = 14; // Teal text for root class name static constexpr int IND_HINT_GREEN = 15; // Green text for hint/comment text static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset in relative mode +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 QString g_fontName = "JetBrains Mono"; @@ -161,9 +163,13 @@ void RcxEditor::setupScintilla() { m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER, IND_CMD_PILL, (long)1); - // Data-changed indicator + // Heatmap indicators (cold / warm / hot) m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, - IND_DATA_CHANGED, 17 /*INDIC_TEXTFORE*/); + IND_HEAT_COLD, 17 /*INDIC_TEXTFORE*/); + m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, + IND_HEAT_WARM, 17 /*INDIC_TEXTFORE*/); + m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, + IND_HEAT_HOT, 17 /*INDIC_TEXTFORE*/); // Root class name — type color m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, @@ -300,8 +306,13 @@ void RcxEditor::applyTheme(const Theme& theme) { IND_HOVER_SPAN, theme.indHoverSpan); m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, IND_CMD_PILL, theme.indCmdPill); + // Heatmap colors m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, - IND_DATA_CHANGED, theme.indDataChanged); + IND_HEAT_COLD, theme.indHeatCold); + m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, + IND_HEAT_WARM, theme.indHeatWarm); + m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, + IND_HEAT_HOT, theme.indHeatHot); m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, IND_CLASS_NAME, theme.syntaxType); m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, @@ -401,7 +412,7 @@ void RcxEditor::applyDocument(const ComposeResult& result) { applyMarkers(result.meta); applyFoldLevels(result.meta); applyHexDimming(result.meta); - applyDataChangedHighlight(result.meta); + applyHeatmapHighlight(result.meta); applyCommandRowPills(); // Reset hint line - applySelectionOverlay will repaint indicators @@ -765,35 +776,48 @@ static QString getLineText(QsciScintilla* sci, int line) { return text; } -void RcxEditor::applyDataChangedHighlight(const QVector& meta) { - for (int i = 0; i < meta.size(); i++) { - if (!meta[i].dataChanged) continue; - if (isSyntheticLine(meta[i])) continue; +void RcxEditor::applyHeatmapHighlight(const QVector& meta) { + static constexpr int heatIndicators[] = { IND_HEAT_COLD, IND_HEAT_WARM, IND_HEAT_HOT }; + for (int i = 0; i < meta.size(); i++) { const LineMeta& lm = meta[i]; + if (isSyntheticLine(lm)) continue; + + int heat = lm.heatLevel; int typeW = lm.effectiveTypeW; int nameW = lm.effectiveNameW; - if (isHexPreview(lm.nodeKind) && !lm.changedByteIndices.isEmpty()) { - // Per-byte highlighting in ASCII + hex areas + // For hex preview nodes: use dataChanged + changedByteIndices (per-byte heat) + 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; - // ASCII column is padded to nameW (aligned with value column) int hexStart = asciiStart + nameW + kSepWidth; for (int byteIdx : lm.changedByteIndices) { - // Highlight in ASCII area (1 char per byte) - fillIndicatorCols(IND_DATA_CHANGED, i, asciiStart + byteIdx, asciiStart + byteIdx + 1); - // Highlight in hex area (2 hex chars per byte at position byteIdx*3) + fillIndicatorCols(IND_HEAT_COLD, i, asciiStart + byteIdx, asciiStart + byteIdx + 1); int hexCol = hexStart + byteIdx * 3; - fillIndicatorCols(IND_DATA_CHANGED, i, hexCol, hexCol + 2); + fillIndicatorCols(IND_HEAT_COLD, i, hexCol, hexCol + 2); } - } else { - // Non-hex nodes: highlight entire value span - QString lineText = getLineText(m_sci, i); - ColumnSpan vs = valueSpan(lm, lineText.size(), typeW, nameW); - if (vs.valid) - fillIndicatorCols(IND_DATA_CHANGED, i, vs.start, vs.end); + 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 + for (int hi : heatIndicators) { + if (hi != activeInd) + clearIndicatorLine(hi, i); } } } @@ -2204,8 +2228,13 @@ void RcxEditor::applyHoverCursor() { return; } - // Mouse left viewport - set Arrow + // Mouse left viewport - set Arrow, cancel calltip if (!m_hoverInside) { + if (m_calltipVisible) { + m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPCANCEL); + m_calltipVisible = false; + m_calltipLine = -1; + } m_sci->viewport()->setCursor(Qt::ArrowCursor); return; } @@ -2294,6 +2323,43 @@ void RcxEditor::applyHoverCursor() { m_hoverSpanLines.append(h.line); } + // Value history calltip on hover + { + bool showCalltip = false; + if (m_valueHistory && h.line >= 0 && h.line < m_meta.size() && !m_editState.active) { + 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, + (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; + } + } + } + } + if (!showCalltip && m_calltipVisible) { + m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPCANCEL); + m_calltipVisible = false; + m_calltipLine = -1; + } + } + // Determine cursor shape based on interaction type Qt::CursorShape desired = Qt::ArrowCursor; diff --git a/src/editor.h b/src/editor.h index c21f480..6b93a31 100644 --- a/src/editor.h +++ b/src/editor.h @@ -54,6 +54,7 @@ public: // Custom type names (struct types from the tree) shown in type picker + lexer GlobalClass coloring QString textWithMargins() const; void setCustomTypeNames(const QStringList& names); + void setValueHistoryRef(const QHash* ref) { m_valueHistory = ref; } // Saved sources for quick-switch in source picker void setSavedSources(const QVector& sources) { m_savedSourceDisplay = sources; } @@ -129,6 +130,11 @@ private: // ── Saved sources for quick-switch ── QVector m_savedSourceDisplay; + // ── Value history ref (owned by controller) ── + const QHash* m_valueHistory = nullptr; + bool m_calltipVisible = false; + int m_calltipLine = -1; + // ── Reentrancy guards ── bool m_clampingSelection = false; bool m_updatingComment = false; @@ -145,7 +151,7 @@ private: void applyMarkers(const QVector& meta); void applyFoldLevels(const QVector& meta); void applyHexDimming(const QVector& meta); - void applyDataChangedHighlight(const QVector& meta); + void applyHeatmapHighlight(const QVector& meta); void applyBaseAddressColoring(const QVector& meta); void applyCommandRowPills(); diff --git a/src/main.cpp b/src/main.cpp index e4f0a7f..5a2f1b7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -67,29 +67,56 @@ static void setDarkTitleBar(QWidget* widget) { } } +// Guard flag to prevent re-entrant crash inside the handler +static volatile LONG s_inCrashHandler = 0; + static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) { + // Prevent re-entrant crash: if we fault inside the handler, skip the + // risky dbghelp work and just terminate with what we already printed. + if (InterlockedCompareExchange(&s_inCrashHandler, 1, 0) != 0) { + fprintf(stderr, "\n(re-entrant fault inside crash handler — aborting)\n"); + fflush(stderr); + return EXCEPTION_EXECUTE_HANDLER; + } + + // Phase 1: always-safe output (no allocations, no complex APIs) fprintf(stderr, "\n=== UNHANDLED EXCEPTION ===\n"); fprintf(stderr, "Code : 0x%08lX\n", ep->ExceptionRecord->ExceptionCode); fprintf(stderr, "Addr : %p\n", ep->ExceptionRecord->ExceptionAddress); +#ifdef _M_X64 + fprintf(stderr, "RIP : 0x%016llx\n", (unsigned long long)ep->ContextRecord->Rip); + fprintf(stderr, "RSP : 0x%016llx\n", (unsigned long long)ep->ContextRecord->Rsp); +#else + fprintf(stderr, "EIP : 0x%08lx\n", (unsigned long)ep->ContextRecord->Eip); +#endif + fflush(stderr); + + // Phase 2: attempt symbol resolution + stack walk + // Copy context so StackWalk64 can mutate it safely + CONTEXT ctxCopy = *ep->ContextRecord; HANDLE process = GetCurrentProcess(); HANDLE thread = GetCurrentThread(); - SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME); - SymInitialize(process, NULL, TRUE); + SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME | SYMOPT_FAIL_CRITICAL_ERRORS); + if (!SymInitialize(process, NULL, TRUE)) { + fprintf(stderr, "\n(SymInitialize failed — no stack trace available)\n"); + fprintf(stderr, "=== END CRASH ===\n"); + fflush(stderr); + return EXCEPTION_EXECUTE_HANDLER; + } - CONTEXT* ctx = ep->ContextRecord; STACKFRAME64 frame = {}; DWORD machineType; #ifdef _M_X64 machineType = IMAGE_FILE_MACHINE_AMD64; - frame.AddrPC.Offset = ctx->Rip; - frame.AddrFrame.Offset = ctx->Rbp; - frame.AddrStack.Offset = ctx->Rsp; + frame.AddrPC.Offset = ctxCopy.Rip; + frame.AddrFrame.Offset = ctxCopy.Rbp; + frame.AddrStack.Offset = ctxCopy.Rsp; #else machineType = IMAGE_FILE_MACHINE_I386; - frame.AddrPC.Offset = ctx->Eip; - frame.AddrFrame.Offset = ctx->Ebp; - frame.AddrStack.Offset = ctx->Esp; + frame.AddrPC.Offset = ctxCopy.Eip; + frame.AddrFrame.Offset = ctxCopy.Ebp; + frame.AddrStack.Offset = ctxCopy.Esp; #endif frame.AddrPC.Mode = AddrModeFlat; frame.AddrFrame.Mode = AddrModeFlat; @@ -97,7 +124,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) { fprintf(stderr, "\nStack trace:\n"); for (int i = 0; i < 64; i++) { - if (!StackWalk64(machineType, process, thread, &frame, ctx, + if (!StackWalk64(machineType, process, thread, &frame, &ctxCopy, NULL, SymFunctionTableAccess64, SymGetModuleBase64, NULL)) break; diff --git a/src/themes/defaults/reclass_dark.json b/src/themes/defaults/reclass_dark.json index 01d9d34..43aef87 100644 --- a/src/themes/defaults/reclass_dark.json +++ b/src/themes/defaults/reclass_dark.json @@ -22,6 +22,9 @@ "indHoverSpan": "#E6B450", "indCmdPill": "#2a2a2a", "indDataChanged": "#8fbc7a", + "indHeatCold": "#569cd6", + "indHeatWarm": "#E6B450", + "indHeatHot": "#f44747", "indHintGreen": "#5a8248", "markerPtr": "#f44747", "markerCycle": "#e5a00d", diff --git a/src/themes/defaults/vs.json b/src/themes/defaults/vs.json index ab02d78..fa243da 100644 --- a/src/themes/defaults/vs.json +++ b/src/themes/defaults/vs.json @@ -22,6 +22,9 @@ "indHoverSpan": "#b180d7", "indCmdPill": "#2d2d30", "indDataChanged": "#8fbc7a", + "indHeatCold": "#569cd6", + "indHeatWarm": "#d69d85", + "indHeatHot": "#f44747", "indHintGreen": "#5a8248", "markerPtr": "#f44747", "markerCycle": "#e5a00d", diff --git a/src/themes/defaults/warm.json b/src/themes/defaults/warm.json index d6028e9..0f50e58 100644 --- a/src/themes/defaults/warm.json +++ b/src/themes/defaults/warm.json @@ -22,6 +22,9 @@ "indHoverSpan": "#AA9565", "indCmdPill": "#2a2a2a", "indDataChanged": "#6B959F", + "indHeatCold": "#6B959F", + "indHeatWarm": "#AA9565", + "indHeatHot": "#A05040", "indHintGreen": "#464646", "markerPtr": "#6B3B21", "markerCycle": "#AA9565", diff --git a/src/themes/theme.cpp b/src/themes/theme.cpp index e97750e..e2d58c1 100644 --- a/src/themes/theme.cpp +++ b/src/themes/theme.cpp @@ -28,6 +28,9 @@ const ThemeFieldMeta kThemeFields[] = { {"indHoverSpan", "Hover Span", "Indicators", &Theme::indHoverSpan}, {"indCmdPill", "Cmd Pill", "Indicators", &Theme::indCmdPill}, {"indDataChanged","Data Changed", "Indicators", &Theme::indDataChanged}, + {"indHeatCold", "Heat Cold", "Indicators", &Theme::indHeatCold}, + {"indHeatWarm", "Heat Warm", "Indicators", &Theme::indHeatWarm}, + {"indHeatHot", "Heat Hot", "Indicators", &Theme::indHeatHot}, {"indHintGreen", "Hint Green", "Indicators", &Theme::indHintGreen}, {"markerPtr", "Pointer", "Markers", &Theme::markerPtr}, {"markerCycle", "Cycle", "Markers", &Theme::markerCycle}, @@ -50,6 +53,14 @@ Theme Theme::fromJson(const QJsonObject& o) { if (o.contains(kThemeFields[i].key)) 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 + if (!t.indHeatCold.isValid()) + t.indHeatCold = t.syntaxKeyword; + if (!t.indHeatWarm.isValid()) + t.indHeatWarm = t.indHoverSpan.isValid() ? t.indHoverSpan : t.syntaxString; + if (!t.indHeatHot.isValid()) + t.indHeatHot = t.markerPtr; return t; } diff --git a/src/themes/theme.h b/src/themes/theme.h index 6cb7234..19db895 100644 --- a/src/themes/theme.h +++ b/src/themes/theme.h @@ -38,7 +38,10 @@ struct Theme { // ── Indicators ── QColor indHoverSpan; // hover link text QColor indCmdPill; // command row pill bg - QColor indDataChanged; // changed data values + QColor indDataChanged; // changed data values (legacy, fallback for old themes) + QColor indHeatCold; // heatmap level 1 (changed once) + QColor indHeatWarm; // heatmap level 2 (moderate changes) + QColor indHeatHot; // heatmap level 3 (frequent changes) QColor indHintGreen; // comment/hint text // ── Markers ── diff --git a/tests/test_core.cpp b/tests/test_core.cpp index 1d0eaa5..cc7a300 100644 --- a/tests/test_core.cpp +++ b/tests/test_core.cpp @@ -583,6 +583,94 @@ private slots: QCOMPARE(norm.size(), 1); QVERIFY(norm.contains(rootId)); } + + // ── ValueHistory tests ── + + void testValueHistory_empty() { + rcx::ValueHistory h; + QCOMPARE(h.heatLevel(), 0); + QCOMPARE(h.uniqueCount(), 0); + QCOMPARE(h.last(), QString()); + } + + void testValueHistory_singleValue() { + rcx::ValueHistory h; + h.record("42"); + QCOMPARE(h.heatLevel(), 0); // only 1 unique → static + QCOMPARE(h.uniqueCount(), 1); + QCOMPARE(h.last(), QString("42")); + } + + void testValueHistory_duplicateIgnored() { + rcx::ValueHistory h; + h.record("42"); + h.record("42"); + h.record("42"); + QCOMPARE(h.count, 1); + QCOMPARE(h.heatLevel(), 0); + } + + void testValueHistory_heatLevels() { + rcx::ValueHistory h; + h.record("a"); + QCOMPARE(h.heatLevel(), 0); // 1 unique + + h.record("b"); + QCOMPARE(h.heatLevel(), 1); // 2 unique → cold + + h.record("c"); + QCOMPARE(h.heatLevel(), 2); // 3 unique → warm + + h.record("d"); + QCOMPARE(h.heatLevel(), 2); // 4 unique → warm + + h.record("e"); + QCOMPARE(h.heatLevel(), 3); // 5 unique → hot + } + + void testValueHistory_ringWrap() { + rcx::ValueHistory h; + // Fill beyond capacity + for (int i = 0; i < 15; i++) + h.record(QString::number(i)); + + QCOMPARE(h.count, 15); + QCOMPARE(h.uniqueCount(), 10); // capped at kCapacity + QCOMPARE(h.heatLevel(), 3); // hot + QCOMPARE(h.last(), QString("14")); + + // Verify oldest values were pushed out, newest 10 remain + QStringList collected; + h.forEach([&](const QString& v) { collected.append(v); }); + QCOMPARE(collected.size(), 10); + QCOMPARE(collected.first(), QString("5")); // oldest surviving + QCOMPARE(collected.last(), QString("14")); // newest + } + + void testValueHistory_forEach() { + rcx::ValueHistory h; + h.record("x"); + h.record("y"); + h.record("z"); + + QStringList items; + h.forEach([&](const QString& v) { items.append(v); }); + QCOMPARE(items.size(), 3); + QCOMPARE(items[0], QString("x")); + QCOMPARE(items[1], QString("y")); + QCOMPARE(items[2], QString("z")); + } + + void testValueHistory_oscillation() { + // Values that oscillate (A → B → A → B) should still count each unique transition + rcx::ValueHistory h; + h.record("A"); + h.record("B"); + h.record("A"); + h.record("B"); + QCOMPARE(h.count, 4); // 4 transitions + QCOMPARE(h.heatLevel(), 2); // warm (count=4 → 3-4 range) + } }; QTEST_MAIN(TestCore)