From 56bcc7dee66f10487be3eb7b9b41ee67ea717227 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:57:54 +0530 Subject: [PATCH 1/2] fix(editor): canvas theme colors for plugin themes styled in CSS Plugin themes registered with only background, foreground and syntax colors in their config style the active line, gutter, cursor and selection in their EditorView.theme. The canvas renderer, which doesn't draw from CSS, showed defaults there. getCanvasTheme now reads those colors from the theme's own style rules (once per theme, no DOM), including an outlined active line (inset box-shadow); the config still wins where it sets a color. A plugin theme registered after the editor was created kept the canvas on One Dark's colors: re-applying the same theme id was skipped. The sync key now includes the registered theme entry. --- src/cm/canvasOptions.ts | 202 +++++++++++++++++++++++++++++-- tests/unit/canvasOptions.test.ts | 100 ++++++++++++++- 2 files changed, 288 insertions(+), 14 deletions(-) diff --git a/src/cm/canvasOptions.ts b/src/cm/canvasOptions.ts index 88133ccb9..1f363ae4e 100644 --- a/src/cm/canvasOptions.ts +++ b/src/cm/canvasOptions.ts @@ -104,6 +104,141 @@ export function findHighlightStyle( ); } +/** + * Editor surface colors a theme's own `EditorView.theme` rules set, for + * the canvas renderer, which doesn't draw from CSS. Plugin themes are often + * registered with only background, foreground and syntax colors in their + * config, and style the active line, gutter and cursor in CSS. + * Parsed from the style modules' rule strings when the canvas theme is + * built (theme changes): no DOM is read, nothing runs per frame. + */ +interface RuleColors { + activeLine?: string; + activeLineBorder?: string; + activeLineBorderWidth?: number; + activeLineGutter?: string; + gutterBackground?: string; + gutterForeground?: string; + gutterActiveForeground?: string; + selection?: string; + cursor?: string; +} + +function collectStyleRules( + extension: unknown, + rules: string[], + seen = new Set(), +): string[] { + if (!extension || typeof extension !== "object" || seen.has(extension)) { + return rules; + } + seen.add(extension); + if (Array.isArray(extension)) { + for (const item of extension) collectStyleRules(item, rules, seen); + return rules; + } + const node = extension as { + getRules?: () => unknown; + value?: unknown; + inner?: unknown; + extension?: unknown; + }; + // A StyleModule (style-mod): one "selectors {declarations}" rule per line. + if (typeof node.getRules === "function") { + const text = node.getRules(); + if (typeof text === "string") rules.push(...text.split("\n")); + return rules; + } + collectStyleRules(node.value, rules, seen); + collectStyleRules(node.inner, rules, seen); + collectStyleRules(node.extension, rules, seen); + return rules; +} + +const COLOR_TOKEN = + /(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)]*\)|#[0-9a-f]{3,8}\b|\b(?!inset\b)[a-z]+\b/i; + +/** A color in a CSS value ("2px solid #333" -> "#333"); null for var(). */ +function colorIn(value: string): string | null { + if (/var\(/.test(value)) return null; + const m = COLOR_TOKEN.exec( + value.replace(/\b(?:solid|dashed|dotted|double|none)\b/g, ""), + ); + if ( + !m || + /^(?:transparent|currentcolor|inherit|initial|unset)$/i.test(m[0]) + ) { + return m && /^transparent$/i.test(m[0]) ? "transparent" : null; + } + return m[0]; +} + +/** An inset box-shadow without offset or blur as a frame (`0 0 0 2px #333 inset`). */ +function insetFrame(value: string): { color: string; width: number } | null { + const first = value.split(/,(?![^(]*\))/)[0]; + if (!/\binset\b/.test(first)) return null; + const color = colorIn(first.replace(/\binset\b/, "")); + const lengths = ( + first.replace(COLOR_TOKEN, " ").match(/-?\d*\.?\d+(?:px)?\b/g) ?? [] + ).map(Number.parseFloat); + const [x = 0, y = 0, blur = 0, spread = 0] = lengths; + if (!color || color === "transparent" || x || y || blur || !(spread > 0)) { + return null; + } + return { color, width: spread }; +} + +export function themeRuleColors(extension: unknown): RuleColors { + const found: RuleColors = {}; + let focusedSelection: string | undefined; + for (const rule of collectStyleRules(extension, [])) { + const m = /^([^{]+)\{([^}]*)\}\s*$/.exec(rule); + if (!m) continue; + const decls = new Map(); + for (const decl of m[2].split(/;(?![^(]*\))/)) { + const i = decl.indexOf(":"); + if (i > 0) decls.set(decl.slice(0, i).trim(), decl.slice(i + 1).trim()); + } + const bg = decls.get("background-color") ?? decls.get("background"); + const bgColor = bg ? colorIn(bg) : null; + for (const sel of m[1].split(",").map((s) => s.trim())) { + // Only the element itself, not states or descendants of it. + if (/:(?:hover|active|focus)|::/.test(sel)) continue; + if (/\.cm-activeLine$/.test(sel)) { + if (bgColor) found.activeLine = bgColor; + const frame = decls.has("box-shadow") + ? insetFrame(decls.get("box-shadow") as string) + : null; + if (frame) { + found.activeLineBorder = frame.color; + found.activeLineBorderWidth = frame.width; + } + } else if (/\.cm-gutters$/.test(sel)) { + if (bgColor) found.gutterBackground = bgColor; + const color = decls.get("color"); + if (color && colorIn(color)) + found.gutterForeground = colorIn(color) as string; + } else if (/\.cm-activeLineGutter$/.test(sel)) { + if (bgColor) found.activeLineGutter = bgColor; + const color = decls.get("color"); + if (color && colorIn(color)) + found.gutterActiveForeground = colorIn(color) as string; + } else if (/\.cm-cursor(?:-primary)?$/.test(sel)) { + const border = + decls.get("border-left-color") ?? decls.get("border-left"); + const color = border ? colorIn(border) : null; + if (color && color !== "transparent") found.cursor = color; + } else if (/\.cm-selectionBackground$/.test(sel) && bgColor) { + // The focused rule is what a focused editor shows. + if (/\.cm-focused\b/.test(sel)) focusedSelection = bgColor; + else found.selection ??= bgColor; + } + } + } + if (focusedSelection) found.selection = focusedSelection; + return found; +} + /** Token colors from a theme config, for themes without a HighlightStyle. */ function highlightFromConfig(config: ThemeConfig): TokenStyleSpec[] { const color = (key: string) => @@ -152,6 +287,15 @@ function themeClasses(config: ThemeConfig): CanvasThemeSpec["classes"] { * EditorView.theme stays in the state, where it styles the DOM UI (tooltips, * panels, autocomplete). */ +/** + * The canvas theme spec with the active line frame, which canvas-view + * releases before it ignore (the line is then drawn without one). + */ +type FramedThemeSpec = CanvasThemeSpec & { + activeLineBorder?: string; + activeLineBorderWidth?: number; +}; + export function getCanvasTheme( themeId: string, seeThrough = false, @@ -162,27 +306,39 @@ export function getCanvasTheme( const config = (theme?.config || getThemeConfig(themeId) || {}) as ThemeConfig; + const extensions = getThemeExtensions(themeId, [oneDark]); const highlight = - findHighlightStyle(getThemeExtensions(themeId, [oneDark])) || - highlightFromConfig(config); + findHighlightStyle(extensions) || highlightFromConfig(config); + // What the config leaves out, from the theme's own CSS rules. + const rules = themeRuleColors(extensions); const background = config.background || "#282c34"; const foreground = config.foreground || "#abb2bf"; - return { + // The config's active line replaces the rules' (fill and frame). + const framed = !config.activeLine && rules.activeLineBorder; + const activeLine = config.activeLine ?? rules.activeLine; + const spec: FramedThemeSpec = { dark: config.dark !== false, // With a picture behind the editor, the canvas is see-through. The // editor element keeps the theme's CSS background under the picture, // and sticky lines take it from there. background: seeThrough ? "transparent" : background, foreground, - selection: config.selection, - cursor: config.cursor, - activeLine: seeThrough - ? translucentActiveLine(config.activeLine, foreground) - : config.activeLine, + selection: config.selection ?? rules.selection, + cursor: config.cursor ?? rules.cursor, + activeLine: + seeThrough && !framed + ? translucentActiveLine(activeLine, foreground) + : (activeLine ?? (framed ? "transparent" : undefined)), + activeLineBorder: framed ? rules.activeLineBorder : undefined, + activeLineBorderWidth: framed ? rules.activeLineBorderWidth : undefined, // Like the DOM themes, which give the active gutter line no background. - activeLineGutter: seeThrough ? "transparent" : background, - gutterForeground: config.lineNumber, - gutterActiveForeground: config.lineNumberActive, + activeLineGutter: seeThrough + ? "transparent" + : (rules.activeLineGutter ?? background), + gutterBackground: seeThrough ? undefined : rules.gutterBackground, + gutterForeground: config.lineNumber ?? rules.gutterForeground, + gutterActiveForeground: + config.lineNumberActive ?? rules.gutterActiveForeground, // A line between the gutter and the code, only when the theme asks. gutterBorder: config.gutterBorder, // Acode draws its own (draggable) scrollbars over the editor. @@ -190,6 +346,7 @@ export function getCanvasTheme( highlight, classes: themeClasses(config), }; + return spec; } /** @@ -371,6 +528,25 @@ interface CanvasSyncState { } const synced = new WeakMap(); + +const themeEntryIds = new WeakMap(); +let nextThemeEntryId = 1; + +/** + * Which registered theme an id resolves to, for the sync key. A plugin + * theme is registered after the editor is created: until then its id falls + * back to One Dark's colors, and applying the same id again once it's + * registered must rebuild the canvas theme (it used to be skipped, leaving + * a cold-started canvas editor in One Dark's colors). + */ +function themeEntryKey(themeId: string): string { + const entry = + getThemeById(themeId) || getThemeById(String(themeId).replace(/-/g, "_")); + if (!entry) return "0"; + let n = themeEntryIds.get(entry); + if (!n) themeEntryIds.set(entry, (n = nextThemeEntryId++)); + return String(n); +} // Acode replaces `setTheme` on editor instances with its own (taking a theme // id), so the view's method is called through the prototype. const setViewTheme = CanvasView.prototype.setTheme; @@ -412,7 +588,7 @@ export function syncCanvasView( if (themeId != null) { const id = String(themeId); const seeThrough = hasPictureBehind(view); - const key = `${id}\0${seeThrough}`; + const key = `${id}\0${seeThrough}\0${themeEntryKey(id)}`; if (key !== state.theme) { state.theme = key; setViewTheme.call(view, getCanvasTheme(id, seeThrough)); @@ -432,6 +608,6 @@ export function markCanvasViewSynced( synced.set(view, { display: JSON.stringify(display), font: JSON.stringify(font), - theme: `${themeId}\0false`, + theme: `${themeId}\0false\0${themeEntryKey(String(themeId))}`, }); } diff --git a/tests/unit/canvasOptions.test.ts b/tests/unit/canvasOptions.test.ts index 0b949f7aa..2c9f594c1 100644 --- a/tests/unit/canvasOptions.test.ts +++ b/tests/unit/canvasOptions.test.ts @@ -1,5 +1,6 @@ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { Compartment, Prec } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; import { tags as t } from "@lezer/highlight"; import { findHighlightStyle, @@ -7,8 +8,9 @@ import { getCanvasDisplayOptions, getCanvasFont, getCanvasTheme, + themeRuleColors, } from "cm/canvasOptions"; -import { getThemeConfig, getThemes } from "cm/themes"; +import { addTheme, getThemeConfig, getThemes, removeTheme } from "cm/themes"; import { describe, expect, it } from "vitest"; describe("canvas renderer theme bridge", () => { @@ -49,6 +51,102 @@ describe("canvas renderer theme bridge", () => { }, }); }); + + // A plugin theme with only text colors in its config, and the rest in + // its EditorView.theme. + const cssOnlyTheme = () => + EditorView.theme( + { + "&": { color: "#bbbbbb", backgroundColor: "#111113" }, + ".cm-cursor, .cm-dropCursor": { borderLeftColor: "#aeafad" }, + ".cm-selectionBackground": { backgroundColor: "rgba(46, 49, 53, 0.5)" }, + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": + { backgroundColor: "#2e3135" }, + ".cm-activeLine": { + backgroundColor: "transparent", + boxShadow: "0 0 0 2px #282828 inset", + }, + ".cm-gutters": { + backgroundColor: "#111113", + color: "#696e77", + border: "none", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + color: "#c6c6c6", + }, + }, + { dark: true }, + ); + + it("reads surface colors from a theme's own rules", () => { + expect(themeRuleColors([cssOnlyTheme()])).toEqual({ + cursor: "#aeafad", + selection: "#2e3135", + activeLine: "transparent", + activeLineBorder: "#282828", + activeLineBorderWidth: 2, + gutterBackground: "#111113", + gutterForeground: "#696e77", + activeLineGutter: "transparent", + gutterActiveForeground: "#c6c6c6", + }); + // Shadows that aren't a plain frame, and var() colors, are left alone. + const other = EditorView.theme({ + ".cm-activeLine": { boxShadow: "0 2px 4px #000 inset" }, + ".cm-cursor": { borderLeftColor: "var(--cursor)" }, + }); + expect(themeRuleColors([other])).toEqual({}); + }); + + it("draws a plugin theme's CSS-only surfaces on the canvas", () => { + addTheme( + "canvas-test-css-theme", + "CSS theme test", + true, + () => [cssOnlyTheme()], + { + name: "canvas-test-css-theme", + dark: true, + background: "#111113", + foreground: "#BBBBBB", + }, + ); + try { + expect(getCanvasTheme("canvas-test-css-theme")).toMatchObject({ + background: "#111113", + cursor: "#aeafad", + selection: "#2e3135", + activeLine: "transparent", + activeLineBorder: "#282828", + activeLineBorderWidth: 2, + gutterBackground: "#111113", + gutterForeground: "#696e77", + gutterActiveForeground: "#c6c6c6", + }); + // The config's own colors win over the rules. + removeTheme("canvas-test-css-theme"); + addTheme( + "canvas-test-css-theme", + "CSS theme test", + true, + () => [cssOnlyTheme()], + { + name: "canvas-test-css-theme", + dark: true, + background: "#111113", + foreground: "#BBBBBB", + activeLine: "#202020", + cursor: "#ff0000", + }, + ); + const spec = getCanvasTheme("canvas-test-css-theme"); + expect(spec).toMatchObject({ activeLine: "#202020", cursor: "#ff0000" }); + expect(spec.activeLineBorder).toBeUndefined(); + } finally { + removeTheme("canvas-test-css-theme"); + } + }); }); describe("canvas renderer settings", () => { From d8796eaf6ada36f24bbe9c5e75b0a6960f3776b6 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:13:45 +0530 Subject: [PATCH 2/2] chore(deps): bump @acode.app/canvas-view to 0.0.8 --- bun.lock | 4 ++-- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 798e9df66..6fc463c8e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "com.foxdebug.acode", "dependencies": { - "@acode.app/canvas-view": "^0.0.7", + "@acode.app/canvas-view": "^0.0.8", "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", "@codemirror/lang-angular": "^0.1.4", @@ -160,7 +160,7 @@ "tar": "^7.5.22", }, "packages": { - "@acode.app/canvas-view": ["@acode.app/canvas-view@0.0.7", "", { "dependencies": { "@codemirror/language": "^6.13.1", "@codemirror/state": "^6.7.6", "@codemirror/view": "^6.43.14", "@lezer/common": "^1.5.5", "@lezer/highlight": "^1.2.5", "style-mod": "^4.1.2" }, "peerDependencies": { "@codemirror/autocomplete": "^6.0.0" }, "optionalPeers": ["@codemirror/autocomplete"] }, "sha512-INIxvczsF3s6oOhLs3YWmPebgj+gOD46PtR3ppTZFHIvDQFQHuSIzK79FxRS/RVrJka47MfCXIIpeTAFGj5MgQ=="], + "@acode.app/canvas-view": ["@acode.app/canvas-view@0.0.8", "", { "dependencies": { "@codemirror/language": "^6.13.1", "@codemirror/state": "^6.7.6", "@codemirror/view": "^6.43.14", "@lezer/common": "^1.5.5", "@lezer/highlight": "^1.2.5", "style-mod": "^4.1.2" }, "peerDependencies": { "@codemirror/autocomplete": "^6.0.0" }, "optionalPeers": ["@codemirror/autocomplete"] }, "sha512-qN1uLNnluIpN3P+vUZe4vtDl5HQZpaIqegeayaR8MxdQyAlAMmlrFf3W87ZRQuU8Jhd35ginTtdCbwFkgLovkQ=="], "@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], diff --git a/package-lock.json b/package-lock.json index 94283e203..5feba18a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.13.5", "license": "MIT", "dependencies": { - "@acode.app/canvas-view": "^0.0.7", + "@acode.app/canvas-view": "^0.0.8", "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", "@codemirror/lang-angular": "^0.1.4", @@ -172,9 +172,9 @@ } }, "node_modules/@acode.app/canvas-view": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@acode.app/canvas-view/-/canvas-view-0.0.7.tgz", - "integrity": "sha512-INIxvczsF3s6oOhLs3YWmPebgj+gOD46PtR3ppTZFHIvDQFQHuSIzK79FxRS/RVrJka47MfCXIIpeTAFGj5MgQ==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@acode.app/canvas-view/-/canvas-view-0.0.8.tgz", + "integrity": "sha512-qN1uLNnluIpN3P+vUZe4vtDl5HQZpaIqegeayaR8MxdQyAlAMmlrFf3W87ZRQuU8Jhd35ginTtdCbwFkgLovkQ==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.13.1", diff --git a/package.json b/package.json index 49b1036dc..4a1aa6dfa 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "ws": "^8.21.3" }, "dependencies": { - "@acode.app/canvas-view": "^0.0.7", + "@acode.app/canvas-view": "^0.0.8", "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", "@codemirror/lang-angular": "^0.1.4",