From b01acb90b849cf306705db4a8e864553ec9bb848 Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Tue, 6 Oct 2026 17:17:19 +0200 Subject: [PATCH] ui: heat map for "highlighted executed lines" --- CHANGELOG.md | 2 +- src/ide/views/editors.ts | 18 +++++++++-- src/ide/views/traceheat.ts | 31 +++++++++++++++++++ src/ide/views/visuals.ts | 26 +++++++++++----- src/themes/editorTheme.ts | 15 +++++++-- test/unit/testtraceheat.ts | 62 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 src/ide/views/traceheat.ts create mode 100644 test/unit/testtraceheat.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a1707d04..713b204d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ See [extension/CHANGELOG.md](extension/CHANGELOG.md). - Editor moved from CodeMirror 5 to CodeMirror 6 with Lezer grammars for 6502, Z80, 6809, Verilog, BASIC, batari BASIC, FastBasic, Inform 6 and Wiz; new Cobalt and MBO themes and better indent handling. - Source search across the project and toolchain headers (F1 for help), with arrow-key navigation, plus symbol search without source. - Shortcut bar and keyboard shortcut help, F8 pause/resume, Cmd+Shift+G go to offset. -- Settings menu with developer options (highlight whitespace, highlight executed lines, line numbers off by default). +- Settings menu with developer options (highlight whitespace, highlight executed lines, line numbers off by default). "Highlight Executed Lines" draws a hot/cold heat map: lines run since the last update are colored by how often they have run (blue = cold, red = hot) and disappear once they stop executing. - Hash routing for windows, and per-page titles. - Mobile layout: tab bar instead of split panes, and a flex toolbar. - Click `#include`/`#link` decorations to open the file; offer to import a GitHub repo given by `repo=` ("user/repo" or a tree URL). diff --git a/src/ide/views/editors.ts b/src/ide/views/editors.ts index ccea1db4..608a802c 100644 --- a/src/ide/views/editors.ts +++ b/src/ide/views/editors.ts @@ -33,6 +33,7 @@ import { Shortcut } from "../shortcutbar"; import { createTextTransformFilterEffect, textTransformFilterCompartment } from "./filters"; import { breakpointMarkers, bytes, clock, currentPcMarker, errorMarkers, ErrorInfo, offset, statusMarkers } from "./gutter"; import { currentPc, errorMessages, errorSpans, highlightLines, showValue, tracedLines } from "./visuals"; +import { computeHeatLevels } from "./traceheat"; // look ahead this many bytes when finding source lines for a PC export const PC_LINE_LOOKAHEAD = 64; @@ -123,6 +124,10 @@ export class SourceEditor implements ProjectView { currentDebugLine: SourceLocation; refreshDelayMsec = 300; probe: ProbeRecorder = null; + // Cumulative executed-line counts for this tracing session. Only lines + // executed in the last update are shown, but their color (hot/cold) comes + // from these totals so it stays stable from tick to tick. + tracedLineCounts = new Map(); createDiv(parent: HTMLElement) { var div = document.createElement('div'); @@ -174,6 +179,7 @@ export class SourceEditor implements ProjectView { this.probe = platform.startProbing(); this.probe.singleFrame = false; // accumulate between our polls, we clear it ourselves this.probe.clear(); + this.tracedLineCounts.clear(); } } @@ -182,6 +188,7 @@ export class SourceEditor implements ProjectView { platform.stopProbing(); this.probe = null; } + this.tracedLineCounts.clear(); this.editor.dispatch({ effects: tracedLines.effect.of([]) }); } @@ -719,17 +726,22 @@ export class SourceEditor implements ProjectView { updateTracedLines() { const p = this.probe; if (!p || !p.idx || !this.sourcefile) return; - const lines = new Set(); + // Accumulate per-line counts since tracing started, and remember which + // lines ran in this update (those are the only ones we'll highlight). + const activeLines = new Set(); for (let i = 0; i < p.idx; i++) { const word = p.buf[i]; if ((word & 0xff000000) === ProbeFlags.EXECUTE) { const loc = this.sourcefile.findLineForOffset(word & 0xffff, PC_LINE_LOOKAHEAD); - if (loc) lines.add(loc.line); + if (loc) { + this.tracedLineCounts.set(loc.line, (this.tracedLineCounts.get(loc.line) || 0) + 1); + activeLines.add(loc.line); + } } } p.clear(); this.editor.dispatch({ - effects: tracedLines.effect.of(Array.from(lines)), + effects: tracedLines.effect.of(computeHeatLevels(this.tracedLineCounts, activeLines)), }); } diff --git a/src/ide/views/traceheat.ts b/src/ide/views/traceheat.ts new file mode 100644 index 00000000..09b89ce5 --- /dev/null +++ b/src/ide/views/traceheat.ts @@ -0,0 +1,31 @@ +// Map per-line execution counts to hot/cold heat levels for the +// "Highlight Executed Lines" overlay. + +export const TRACED_HEAT_LEVELS = 4; + +// Convert one count to a level 0..TRACED_HEAT_LEVELS-1, relative to the +// maximum count. Uses a log scale so a few very hot lines don't flatten +// everything else into the coldest bucket. +export function heatLevel(count: number, max: number): number { + if (max <= 1 || count <= 1) return 0; + const level = Math.round((Math.log(count) / Math.log(max)) * (TRACED_HEAT_LEVELS - 1)); + return Math.max(0, Math.min(TRACED_HEAT_LEVELS - 1, level)); +} + +// Compute hot/cold levels for lines in a set of execution counts. +// Levels are normalized against the maximum over ALL counts, so a line's +// color stays stable even when it isn't in the current update. If `visible` +// is given, only those lines are returned; the caller uses this to hide +// lines that haven't run since the last update. +export function computeHeatLevels(counts: Map, visible?: Set): { line: number, level: number }[] { + let max = 0; + for (const count of counts.values()) { + if (count > max) max = count; + } + const lines: { line: number, level: number }[] = []; + for (const [line, count] of counts) { + if (visible && !visible.has(line)) continue; + lines.push({ line, level: heatLevel(count, max) }); + } + return lines; +} diff --git a/src/ide/views/visuals.ts b/src/ide/views/visuals.ts index 94dbeb45..36b0efcd 100644 --- a/src/ide/views/visuals.ts +++ b/src/ide/views/visuals.ts @@ -1,5 +1,6 @@ import { errorMarkers } from "./gutter"; +import { TRACED_HEAT_LEVELS } from "./traceheat"; import { SelectionRange, StateEffect, StateField } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView, WidgetType } from "@codemirror/view"; @@ -263,12 +264,22 @@ export const highlightLines = { field: highlightLinesField, }; -// Highlight lines that were recently executed, per live trace data. -const tracedLinesEffect = StateEffect.define(); +// Highlight recently-executed lines, colored by execution frequency (hot/cold). +// A line's level is 0 (coldest) .. TRACED_HEAT_LEVELS-1 (hottest). +export interface TracedLine { + line: number; + level: number; +} -const tracedLineDecoration = Decoration.line({ - attributes: { class: "cm-traced-line" } -}); +const tracedLinesEffect = StateEffect.define(); + +// one line decoration per heat level +const tracedLineDecorations = []; +for (let i = 0; i < TRACED_HEAT_LEVELS; i++) { + tracedLineDecorations.push(Decoration.line({ + attributes: { class: `cm-traced-line cm-traced-line-h${i}` } + })); +} const tracedLinesField = StateField.define({ create() { return Decoration.none }, @@ -278,10 +289,11 @@ const tracedLinesField = StateField.define({ for (let e of tr.effects) { if (e.is(tracedLinesEffect)) { const ranges: any[] = []; - for (const lineNum of e.value) { + for (const { line: lineNum, level } of e.value) { try { const line = tr.state.doc.line(lineNum); - ranges.push(tracedLineDecoration.range(line.from)); + const deco = tracedLineDecorations[Math.max(0, Math.min(TRACED_HEAT_LEVELS - 1, level | 0))]; + ranges.push(deco.range(line.from)); } catch { // Line doesn't exist, skip } diff --git a/src/themes/editorTheme.ts b/src/themes/editorTheme.ts index 53ab6285..ce4fa52f 100644 --- a/src/themes/editorTheme.ts +++ b/src/themes/editorTheme.ts @@ -17,8 +17,19 @@ export const editorTheme = EditorView.theme({ ".highlight-lines": { backgroundColor: "#003399 !important", }, - ".cm-traced-line": { - backgroundColor: "rgba(129, 123, 249, 0.15)", + // Executed-line heat map: cold (rarely run) -> hot (frequently run). + ".cm-traced-line": {}, + ".cm-traced-line-h0": { + backgroundColor: "rgba(70, 120, 255, 0.18)", + }, + ".cm-traced-line-h1": { + backgroundColor: "rgba(80, 210, 170, 0.20)", + }, + ".cm-traced-line-h2": { + backgroundColor: "rgba(255, 200, 60, 0.22)", + }, + ".cm-traced-line-h3": { + backgroundColor: "rgba(255, 70, 50, 0.28)", }, ".cm-error-span": { textDecoration: "underline wavy red", diff --git a/test/unit/testtraceheat.ts b/test/unit/testtraceheat.ts new file mode 100644 index 00000000..01d946c5 --- /dev/null +++ b/test/unit/testtraceheat.ts @@ -0,0 +1,62 @@ +import assert from "assert"; +import { describe, it } from "mocha"; +import { computeHeatLevels, heatLevel, TRACED_HEAT_LEVELS } from "../../src/ide/views/traceheat"; + +// The "Highlight Executed Lines" overlay colors source lines by how often +// they run (cold -> hot). These tests pin down the level mapping so the +// color scale doesn't silently change. + +describe("trace heat levels", function () { + it("puts an isolated count in the coldest bucket", function () { + assert.equal(heatLevel(1, 1), 0); // nothing to compare against + assert.equal(heatLevel(1, 1000), 0); + assert.equal(heatLevel(2, 1000), 0); + }); + + it("maps the maximum count to the hottest bucket", function () { + assert.equal(heatLevel(1000, 1000), TRACED_HEAT_LEVELS - 1); + assert.equal(heatLevel(2, 2), TRACED_HEAT_LEVELS - 1); + }); + + it("increases monotonically with count", function () { + let prev = -1; + for (const count of [1, 2, 4, 8, 16, 64, 256, 1024]) { + const level = heatLevel(count, 1024); + assert.ok(level >= prev, `level for ${count} went backwards`); + assert.ok(level >= 0 && level < TRACED_HEAT_LEVELS); + prev = level; + } + }); + + it("keeps cold lines cold when a few lines are very hot", function () { + // one line dominates; a rarely-run line should not be pulled hot + assert.ok(heatLevel(1, 100000) < heatLevel(100, 100000)); + assert.ok(heatLevel(100, 100000) < heatLevel(100000, 100000)); + }); + + it("computes levels for every line", function () { + const levels = computeHeatLevels(new Map([[10, 1], [20, 100], [30, 10000]])); + const byLine = new Map(levels.map(l => [l.line, l.level])); + assert.equal(byLine.size, 3); + assert.ok(byLine.get(10) < byLine.get(20)); + assert.ok(byLine.get(20) < byLine.get(30)); + assert.equal(byLine.get(30), TRACED_HEAT_LEVELS - 1); + }); + + it("only returns lines visible in the current update", function () { + const counts = new Map([[10, 1], [20, 100], [30, 10000]]); + const levels = computeHeatLevels(counts, new Set([30])); + assert.deepEqual(levels.map(l => l.line), [30]); + // color still comes from the global maximum + assert.equal(levels[0].level, TRACED_HEAT_LEVELS - 1); + }); + + it("returns nothing when no lines are visible", function () { + const counts = new Map([[10, 1], [20, 100]]); + assert.deepEqual(computeHeatLevels(counts, new Set()), []); + }); + + it("handles an empty map", function () { + assert.deepEqual(computeHeatLevels(new Map()), []); + }); +});