Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
202 changes: 189 additions & 13 deletions src/cm/canvasOptions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {

Check failure on line 1 in src/cm/canvasOptions.ts

View workflow job for this annotation

GitHub Actions / Unit Tests (On PR Only)

tests/unit/canvasOptions.test.ts

ReferenceError: Element is not defined ❯ node_modules/@acode.app/canvas-view/dist/index.js:8766:55 ❯ src/cm/canvasOptions.ts:1:1
type CanvasThemeSpec,
CanvasView,
type CanvasViewConfig,
Expand Down Expand Up @@ -104,6 +104,141 @@
);
}

/**
* 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<unknown>(),
): 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<string, string>();
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) =>
Expand Down Expand Up @@ -152,6 +287,15 @@
* 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,
Expand All @@ -162,34 +306,47 @@
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.
scrollbar: "transparent",
highlight,
classes: themeClasses(config),
};
return spec;
}

/**
Expand Down Expand Up @@ -371,6 +528,25 @@
}

const synced = new WeakMap<CanvasView, CanvasSyncState>();

const themeEntryIds = new WeakMap<object, number>();
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;
Expand Down Expand Up @@ -412,7 +588,7 @@
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));
Expand All @@ -432,6 +608,6 @@
synced.set(view, {
display: JSON.stringify(display),
font: JSON.stringify(font),
theme: `${themeId}\0false`,
theme: `${themeId}\0false\0${themeEntryKey(String(themeId))}`,
});
}
Loading
Loading