diff --git a/js/appearance/index.js b/js/appearance/index.js index 72f98be..bd88838 100644 --- a/js/appearance/index.js +++ b/js/appearance/index.js @@ -2,6 +2,7 @@ import { app } from "/scripts/app.js"; import { DEFAULT_SCHEME, NODE_COLOR_SCHEMES, + collectGraphNodes, schemeColors, shouldRecolor, titleInk, @@ -30,10 +31,9 @@ function applyScheme(node, colors) { } function repaintAll(nextScheme, previousScheme) { - const nodes = app.graph?._nodes || app.graph?.nodes || []; const next = schemeColors(nextScheme); const previous = schemeColors(previousScheme); - for (const node of nodes) { + for (const node of collectGraphNodes(app.graph)) { if (!isAusbossNode(node)) continue; if (!shouldRecolor(node, previous)) continue; applyScheme(node, next); diff --git a/js/shared/appearance.mjs b/js/shared/appearance.mjs index 12c5035..9019a8d 100644 --- a/js/shared/appearance.mjs +++ b/js/shared/appearance.mjs @@ -36,6 +36,22 @@ export function titleInk(hex) { return luminance > 0.6 ? "#1a1a1a" : "#ffffff"; } +// Flatten every node reachable from a graph, descending into subgraph +// containers via `node.subgraph`. Accepts a graph exposing `_nodes` or +// `nodes`, or a plain node array; the visited set breaks reference cycles. +export function collectGraphNodes(graph, visited = new Set()) { + if (!graph || visited.has(graph)) return []; + visited.add(graph); + const nodes = Array.isArray(graph) ? graph : graph._nodes || graph.nodes || []; + const collected = []; + for (const node of nodes) { + if (!node) continue; + collected.push(node); + if (node.subgraph) collected.push(...collectGraphNodes(node.subgraph, visited)); + } + return collected; +} + // A node is repainted only while it is still "following the setting": either // it carries no colors while the previous scheme was the theme default, or it // still wears exactly the previous scheme's pair. Anything else — a color the diff --git a/tests/appearance.test.mjs b/tests/appearance.test.mjs index 8cab22b..4d7e454 100644 --- a/tests/appearance.test.mjs +++ b/tests/appearance.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { DEFAULT_SCHEME, NODE_COLOR_SCHEMES, + collectGraphNodes, schemeColors, shouldRecolor, titleInk, @@ -78,3 +79,30 @@ test("titleInk falls back to light ink on malformed input", () => { assert.equal(titleInk("e0e0e0"), "#ffffff"); assert.equal(titleInk("#zzzzzz"), "#ffffff"); }); + +test("collectGraphNodes flattens nested subgraphs", () => { + const inner = { _nodes: [{ id: 3 }] }; + const graph = { _nodes: [{ id: 1 }, { id: 2, subgraph: inner }] }; + assert.deepEqual( + collectGraphNodes(graph).map((node) => node.id), + [1, 2, 3], + ); +}); + +test("collectGraphNodes accepts nodes arrays and plain arrays", () => { + const viaNodes = { nodes: [{ id: 1 }] }; + assert.deepEqual(collectGraphNodes(viaNodes).map((node) => node.id), [1]); + const plain = [{ id: 1 }, { id: 2, subgraph: [{ id: 3 }] }]; + assert.deepEqual(collectGraphNodes(plain).map((node) => node.id), [1, 2, 3]); + assert.deepEqual(collectGraphNodes(null), []); + assert.deepEqual(collectGraphNodes({}), []); +}); + +test("collectGraphNodes survives subgraph reference cycles", () => { + const graph = { _nodes: [{ id: 1 }] }; + graph._nodes.push({ id: 2, subgraph: graph }); + assert.deepEqual( + collectGraphNodes(graph).map((node) => node.id), + [1, 2], + ); +});