diff --git a/.github/workflows/github-action-test-python.yml b/.github/workflows/github-action-test-python.yml index dbef70c..2170f05 100644 --- a/.github/workflows/github-action-test-python.yml +++ b/.github/workflows/github-action-test-python.yml @@ -8,7 +8,7 @@ on: jobs: build-test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: # Run all versions to the end, even if one of them fails fail-fast: false diff --git a/src/programflow-visualization/web/graph-renderer.ts b/src/programflow-visualization/web/graph-renderer.ts index c1d87ec..df35ed2 100644 --- a/src/programflow-visualization/web/graph-renderer.ts +++ b/src/programflow-visualization/web/graph-renderer.ts @@ -28,7 +28,7 @@ const MARGIN_Y = 24; */ const MARGIN_X = 8; -/** Room above the nodes for the "Frames" / "Objects" captions. */ +/** Room above the nodes for the "Variables" / "Objects" captions. */ const BAND_HEIGHT = 28; /** @@ -189,6 +189,12 @@ export function renderGraph( const edgesIntoNode = new Map(); const edgesOutOfNode = new Map(); const edgesBySourcePort = new Map(); + // The node at the far end of each edge's path, keyed by the path itself (never the + // badge): lets a hover on one end light up the node the arrow actually reaches, see + // `otherEndNodes` below. + const edgeEndpoints = new Map(); + // Filled in as nodes are built below; `paint` looks a peer node's element up here. + const nodeElementsById = new Map(); const track = ( index: Map, key: string | undefined, @@ -243,9 +249,14 @@ export function renderGraph( parts.push(badge); } - track(edgesOutOfNode, portToNode.get(edge.sources[0]), parts); - track(edgesIntoNode, portToNode.get(edge.targets[0]), parts); + const sourceNode = portToNode.get(edge.sources[0]); + const targetNode = portToNode.get(edge.targets[0]); + track(edgesOutOfNode, sourceNode, parts); + track(edgesIntoNode, targetNode, parts); track(edgesBySourcePort, edge.sources[0], parts); + if (sourceNode && targetNode) { + edgeEndpoints.set(path, { source: sourceNode, target: targetNode }); + } } container.append(svg); @@ -259,10 +270,35 @@ export function renderGraph( /** `row` washes the row itself; both `box` kinds outline the whole box. */ kind: "row" | "box-incoming" | "box-all"; edges: SVGElement[]; + /** The node(s) at the far end of `edges` -- what the arrow(s) actually reach. */ + peerNodeIds: Set; }; let active: Hover | undefined; + /** + * The node at the other end of each of `edges` from `ownNodeId`, so that pointing at + * a variable or attribute also picks out the object its arrow reaches (and pointing + * at a box that is pointed to picks out whoever points at it). Self-loops -- an + * object referencing itself -- contribute nothing, since that end is already + * `ownNodeId`. + */ + const otherEndNodes = (edges: SVGElement[], ownNodeId: string): Set => { + const peers = new Set(); + for (const edge of edges) { + const ends = edgeEndpoints.get(edge); + if (!ends) { + continue; + } + if (ends.source === ownNodeId && ends.target !== ownNodeId) { + peers.add(ends.target); + } else if (ends.target === ownNodeId && ends.source !== ownNodeId) { + peers.add(ends.source); + } + } + return peers; + }; + /** * The edges leaving one row. Two of them when the row is a dict entry whose key is a * reference: that row owns a second port, and both arrows leave the row being pointed @@ -301,14 +337,16 @@ export function renderGraph( if (row) { const leaving = edgesLeavingRow(row); return leaving.length > 0 - ? { element: row, kind: "row", edges: leaving } - : { element: node, kind: "box-incoming", edges: incoming }; + ? { element: row, kind: "row", edges: leaving, peerNodeIds: otherEndNodes(leaving, nodeId) } + : { + element: node, + kind: "box-incoming", + edges: incoming, + peerNodeIds: otherEndNodes(incoming, nodeId), + }; } - return { - element: node, - kind: "box-all", - edges: [...incoming, ...(edgesOutOfNode.get(nodeId) ?? [])], - }; + const edges = [...incoming, ...(edgesOutOfNode.get(nodeId) ?? [])]; + return { element: node, kind: "box-all", edges, peerNodeIds: otherEndNodes(edges, nodeId) }; }; const paint = (on: boolean) => { @@ -325,6 +363,9 @@ export function renderGraph( } else { active.element.classList.toggle("elk-node-active", on); } + for (const peerId of active.peerNodeIds) { + nodeElementsById.get(peerId)?.classList.toggle("elk-node-active", on); + } for (const path of active.edges) { path.classList.toggle("elk-edge-active", on); } @@ -353,6 +394,7 @@ export function renderGraph( } const element = renderNode(model); element.dataset.nodeId = child.id; + nodeElementsById.set(child.id, element); element.style.left = `${(child.x ?? 0) + offsetX}px`; element.style.top = `${(child.y ?? 0) + offsetY}px`; element.style.width = `${child.width ?? 0}px`; @@ -400,7 +442,7 @@ export function renderGraph( // Column captions, derived from where the nodes actually ended up: there are no // container nodes to hang them off (plan 4). if (framesRight > Number.NEGATIVE_INFINITY) { - container.append(band("Frames", "elk-band-frames", MARGIN_X, framesRight)); + container.append(band("Variables", "elk-band-frames", MARGIN_X, framesRight)); } if (objectsLeft < Number.POSITIVE_INFINITY) { container.append( diff --git a/src/programflow-visualization/web/node-view.ts b/src/programflow-visualization/web/node-view.ts index c1fdd35..81d9591 100644 --- a/src/programflow-visualization/web/node-view.ts +++ b/src/programflow-visualization/web/node-view.ts @@ -49,6 +49,12 @@ export function renderNode(model: NodeModel): HTMLElement { header.setAttribute("aria-expanded", String(!model.collapsed)); header.append(div("elk-caret", model.collapsed ? "\u25B8" : "\u25BE")); } + if (model.kind === "current-frame") { + // The frame currently executing, marked the way a debugger marks the active + // stack frame, since the border-left accent alone reads as just another colour + // (plan 6.4). + header.append(div("elk-current-frame-marker", "\u25B6")); + } header.append(div("elk-node-title", model.header)); node.append(header); diff --git a/src/programflow-visualization/web/webview.css b/src/programflow-visualization/web/webview.css index 0ce431e..df9fe24 100644 --- a/src/programflow-visualization/web/webview.css +++ b/src/programflow-visualization/web/webview.css @@ -357,17 +357,38 @@ body { background-image: linear-gradient(var(--wypp-frame-bg), var(--wypp-frame-bg)); } -.elk-frame .elk-node-header, -.elk-current-frame .elk-node-header { +.elk-frame .elk-node-header { background-color: var(--wypp-frame-header-bg); } +/* The current frame's header gets its own, stronger tint -- rather than sharing the + wash every frame gets -- so the stack's one active frame reads as different at a + glance, not just differently outlined (plan 6.4). */ +.elk-current-frame .elk-node-header { + background-color: color-mix( + in srgb, + var(--wypp-accent-current-frame) 32%, + var(--wypp-frame-header-bg) + ); +} + .elk-frame { border-left-color: var(--wypp-accent-frame); } .elk-current-frame { border-left-color: var(--wypp-accent-current-frame); + /* Wider than the 4px every other node gets, on top of the colour difference: the + stack has exactly one current frame, and it is worth a second cue. */ + border-left-width: 6px; +} + +/* The small triangle in the current frame's header, echoing the arrow a debugger + points at the active stack frame with. */ +.elk-current-frame-marker { + flex: 0 0 auto; + color: var(--wypp-accent-current-frame); + font-size: 9px; } /* One accent per heap type, so kind is readable at a glance and at small zoom