ui: heat map for "highlighted executed lines"

This commit is contained in:
Steven Hugg committed 2026-10-06 18:23:12 +02:00
1 parent b0c9dccf2c
commit b01acb90b8
6 files changed
+141 -13

No files matched your search

+1 -1
View File
@@ -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).
+15 -3
View File
@@ -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<number, number>();
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<number>();
// 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<number>();
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)),
});
}
+31
View File
@@ -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<number, number>, visible?: Set<number>): { 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;
}
+19 -7
View File
@@ -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<number[]>();
// 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<TracedLine[]>();
// 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
}
+13 -2
View File
@@ -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",
+62
View File
@@ -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()), []);
});
});