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", 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", () => {