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
65 changes: 55 additions & 10 deletions OPEN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,61 @@ Actionable open tasks for glsl.10k24.com, tracked here until they're scoped and

---

## 7. Autocomplete: suggest variable members (`.field` after a typed variable)
## 7. ✅ Done — Autocomplete: suggest variable members (`.field` after a typed variable)

**Goal:** Add member completion. When a user types `q.` (a variable of known type), offer that type's members. E.g. for `vec4 q = vec4(1.0);`, typing `q.` should suggest `x`, `y`, `z`, `w`, `r`, `g`, `b`, `a`, `s`, `t`, `p`, `q`, array access, etc.
**Status:** **Implemented.** Vector swizzle accessors, matrix `[0][0]` skeleton, and constructor options all ship.

**Approach / notes:**
- Current autocomplete (`src/glsl-completions.ts`) is keyword/function-only; extend it (or add a second completion source) that resolves the identifier left of the `.` to a declared variable's type.
- Track in-scope variable declarations (name → type) as the user types — a lightweight GLSL parse of the current doc (regex/text scan, not a full parser; see existing parser scans in `src/glsl-preprocessor.ts`).
- Member lists per type: `vecN` (component accessors, swizzling, array `[i]`), `matN`, `sampler2D` (`texture` isn't a member but may imply usage), structs can't be user-defined in Shadertoy, so built-in types cover most of it.
- Files: `src/glsl-completions.ts` (add member source), `src/editor.ts` (register source), plus tests.
**What was built:**
- **`src/glsl-vars.ts`** (new deep module): `scanVariables()` (name → type regex scan of the doc), `membersForType()`, `constructorCompletions()`, `KNOWN_TYPES` set.
- **`src/glsl-completions.ts`**: routes completion by context — bracket (`m[`), dot (`q.`), and word (keyword / function / type / constructor).
- **`src/editor.ts`**: `memberTriggerKeymap` + `insertThenComplete` fire `startCompletion` after typing `.` / `[`.
- **ON by default** (`index.html` `#ac-checkbox` `checked` + boot calls `setAutocomplete(true)`), **preference persisted** in `localStorage` (`glsl.autocomplete`), re-read on load.
- **Context hints** in the popup, dimmed via `.cm-completionDetail` opacity: swizzle members carry `"x component"`, `"red channel"`, `"s texture coord"`, etc.; matrix skeleton shows `"index skeleton"`; constructors show `"constructor"`.
- Vectors expose `.x .y .z .w .r .g .b .a .s .t .p .q`; matrices a `m[0][0]` skeleton with cursor landing on the row index; bare type keyword + constructor both offered for type words. Scalars/mat/sampler have no dot-notation (design decision).

**Coverage:** `test/glsl-vars.test.ts` (unit), `test/autocomplete.e2e.ts` (4 e2e), localStorage persistence covered in `test/boot.e2e.ts`. Full suite green (56 unit, 21 e2e) as of this update.

**Deferred (captured from open decisions, if revisited):** copy-propagation / reassignment type tracking (currently resolves to last declaration).

---

## 8. GA4 event tracking: `shader_share` + `presentation_start`

**Goal:** Send two custom GA4 events with the shader's share-URL, content-fingerprinted.

**Scope (trimmed from a larger proposal — user wants only these two):**
- `shader_share` — fired on Share button click.
- `presentation_start` — fired on Present button click (also captures the shader URL).
- Explicit **non-goals**: no `compile_error`, `autocomplete_toggle`, `shader_reset`, `render_toggle`, `define_toggle`, `presentation_end`; no dev-gating change (gtag.js loads as-is, including on localhost).

**Key constraint (why we fingerprint):** GA4 caps custom event parameter *values* at **100 chars** (only `page_location` gets 1,000). A full compressed `#s=` shader URL is typically hundreds of chars and would be truncated, so it's never sent raw. Instead:
- Send a **SHA-1 fingerprint** (hex, 40 chars — well under the limit) of the full share-URL as a `shader_url` param. Collision-resistant for dedupe, privacy-safe, no truncation ambiguity.
- SHA-1 chosen over MD5: exposed directly by `crypto.subtle.digest` (built-in WebCrypto, zero deps, works on https + localhost); MD5 would need a dependency. Collision risk negligible for dedupe.
- GA4 does not auto-capture the shared link (it only sees `page_location` = the clean address bar), so the event is the only way to get it.

**Design (deep module, single source of truth for GA naming/limits):**
- New `src/analytics.ts`:
- `track(event, params)` — no-ops safely when `typeof gtag !== "function"` (GA absent/blocked).
- `fingerprint(text)` — `crypto.subtle.digest("SHA-1", ...)` → hex.
- `EVENTS` constants — event/param names defined once.
- `src/main.ts`:
- Reuse the existing URL-building logic from the Share click handler (default shader → clean URL; else `base + "#" + encodeShare(doc, overrides)`).
- `shader_share`: `track(EVENTS.shaderShare, { shader_url: await fingerprint(url), copied: "success" | "failed" })` (`copied` from the clipboard promise — cheap signal that sharing actually worked).
- `presentation_start`: build the same share-URL and `track(EVENTS.presentationStart, { shader_url: await fingerprint(url) })`; Present handler becomes async.

**Coverage:** new `test/analytics.test.ts` — stub `window.gtag`, assert `track()` forwards the correct event name + params; assert `fingerprint()` returns a stable 40-char hex; assert `track()` no-ops when `gtag` is undefined. (`crypto.subtle` is available in Vitest's Node runtime — no mock needed.)

**Open / out-of-band:** registering `shader_url` (+ `copied`) as a **custom dimension** in the GA4 dashboard must be done manually to make the params reportable.

---

## 9. Intermittent e2e flake — matrix bracket skeleton (`m[` → `m[0][0]`)

**Status:** Known intermittent infra/timing flake in `test/autocomplete.e2e.ts:60` ("typing `[` after a declared matrix applies a bracket skeleton").

**Symptom:** On a cold first-run of the Playwright process (dev server just started), pressing Enter after `m[` sometimes resolves the doc to `m[ ` (snippet fields rendered empty on a trailing line) instead of `m[0][0]`. Warm/subsequent runs and the full-suite invocation (`bun run test:e2e`) pass 22/22.

**Root cause (not yet confirmed):** Cold-start timing between `insertThenComplete` (inserts `[` + calls `startCompletion`) and CM6's snippet field application — not a product-logic bug in the matrix path. Reproduces ~0-2/6 in fresh isolated processes; passes consistently once warm. Pre-existing (observed before the Sourcery review touched `src/glsl-vars.ts`/`glsl-completions.ts`); unrelated to those changes.

**Actions if it reappears in CI:** add a short settle wait before the Enter assertion, or verify the applied doc with a retrying assertion. Otherwise leave as-is — the full-suite run is green.

**Open decisions:**
- Handle copy-propagation / reassignment (e.g. `q = somethingElse` changes `q`'s type), or resolve to the first declaration only for v1.
- Whether to include `matN` prototype-style members and array indexing suggestions, or just the simple vector component accessors first.
51 changes: 26 additions & 25 deletions docs/assets/index-DFKXbf_y.js → docs/assets/index-CmDAv2Rh.js

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,23 @@

<link rel="stylesheet" href="https://use.typekit.net/rli7dce.css">

<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-CPZFEJK5BG"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());

gtag('config', 'G-CPZFEJK5BG');
</script>

<link rel="icon" type="image/png" href="/img/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg" />
<link rel="shortcut icon" href="/img/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="GLSL Editor" />
<link rel="manifest" href="/img/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-DFKXbf_y.js"></script>
<script type="module" crossorigin src="/assets/index-CmDAv2Rh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B6g82T-4.css">
</head>
<body>
Expand All @@ -50,7 +60,7 @@
</div>
<div id="toolbar">
<label id="ac-toggle">
<input type="checkbox" id="ac-checkbox"> Autocomplete
<input type="checkbox" id="ac-checkbox" checked> Autocomplete
</label>
<button id="pause-btn" type="button" title="Pause or resume rendering">Pause</button>
<button id="reset-btn" type="button" title="Restore the default shader">Reset</button>
Expand Down
12 changes: 11 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@

<link rel="stylesheet" href="https://use.typekit.net/rli7dce.css">

<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-CPZFEJK5BG"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());

gtag('config', 'G-CPZFEJK5BG');
</script>

<link rel="icon" type="image/png" href="/img/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/img/favicon.svg" />
<link rel="shortcut icon" href="/img/favicon.ico" />
Expand All @@ -48,7 +58,7 @@
</div>
<div id="toolbar">
<label id="ac-toggle">
<input type="checkbox" id="ac-checkbox"> Autocomplete
<input type="checkbox" id="ac-checkbox" checked> Autocomplete
</label>
<button id="pause-btn" type="button" title="Pause or resume rendering">Pause</button>
<button id="reset-btn" type="button" title="Restore the default shader">Reset</button>
Expand Down
25 changes: 23 additions & 2 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,33 @@ import { defaultKeymap, history, historyKeymap, indentWithTab, undo, redo } from
import { StreamLanguage, bracketMatching, indentOnInput } from "@codemirror/language";
import { shader } from "@codemirror/legacy-modes/mode/clike";
import { oneDark } from "@codemirror/theme-one-dark";
import { autocompletion, closeBrackets, completionKeymap, acceptCompletion } from "@codemirror/autocomplete";
import { autocompletion, closeBrackets, completionKeymap, acceptCompletion, startCompletion } from "@codemirror/autocomplete";
import { glslCompletionSource } from "./glsl-completions";
import { ERROR_GUTTER_CLASS, ERROR_LINE_CLASS, ERROR_MARKER_CLASS, setErrorLinesEffect, errorLinesField, errorGutter, parseErrorLines } from "./error-lines";

const glslLang = StreamLanguage.define(shader);
const autocompleteComp = new Compartment();

// Typing `.` or `[` after an identifier should open member completion for the
// variable left of it. CM6 only auto-opens the popup on word characters, so
// these keys insert the trigger char and then explicitly start completion.
const memberTriggerKeymap = keymap.of([
{ key: ".", run: insertThenComplete(".") },
{ key: "[", run: insertThenComplete("[") },
]);

function insertThenComplete(ch: string): (view: EditorView) => boolean {
return (view) => {
const { from, to } = view.state.selection.main;
view.dispatch({
changes: { from, to, insert: ch },
selection: { anchor: from + ch.length },
});
startCompletion(view);
return true;
};
}

const baseTheme = EditorView.theme({
"&": { height: "100%", backgroundColor: "#0d0f14" },
".cm-scroller": { overflow: "auto", height: "100%" },
Expand Down Expand Up @@ -58,7 +78,7 @@ const baseTheme = EditorView.theme({
color: "#c0caf5",
},
".cm-completionLabel": { color: "#c0caf5" },
".cm-completionDetail": { color: "#7aa2f7", fontSize: "0.85rem", fontStyle: "normal", marginLeft: "8px" },
".cm-completionDetail": { color: "#7aa2f7", opacity: 0.55, fontSize: "0.85rem", fontStyle: "normal", marginLeft: "8px" },
".cm-completionMatchedText": { color: "#9ece6a", fontWeight: "700", textDecoration: "none" },
// Info popup beside completion
".cm-tooltip.cm-completionInfo": {
Expand Down Expand Up @@ -99,6 +119,7 @@ export function createEditor(
errorLinesField,
errorGutter,
Prec.highest(keymap.of([{ key: "Tab", run: acceptCompletion }])),
Prec.high(memberTriggerKeymap),
Prec.high(keymap.of(completionKeymap)),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
EditorView.lineWrapping,
Expand Down
60 changes: 50 additions & 10 deletions src/glsl-completions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
import { GLSL_DOCS, DocKind } from "./glsl-docs";
import { scanVariables, membersForType, constructorCompletions } from "./glsl-vars";

const CM_TYPE: Record<DocKind, Completion["type"]> = {
function: "function",
Expand All @@ -9,7 +10,7 @@ const CM_TYPE: Record<DocKind, Completion["type"]> = {
keyword: "keyword",
};

// Build the completion list once — it never changes
// Build the keyword/function completion list once — never changes
const GLSL_COMPLETIONS: Completion[] = Object.entries(GLSL_DOCS).map(([label, doc]) => ({
label,
type: CM_TYPE[doc.kind],
Expand All @@ -18,25 +19,64 @@ const GLSL_COMPLETIONS: Completion[] = Object.entries(GLSL_DOCS).map(([label, do
boost: doc.kind === "function" ? 1 : 0,
}));

// Resolver: look up the type of an identifier left of a `.` or `[` in the current doc.
function resolveType(ctx: CompletionContext, identEnd: number): string | null {
const line = ctx.state.doc.lineAt(ctx.pos).text;
const start = identEnd - 1;
if (start < 0) return null;
const ident = line.slice(0, identEnd).match(/(\w+)$/);
if (!ident) return null;
const vars = scanVariables(ctx.state.doc.toString());
return vars.get(ident[1]) ?? null;
}

export function glslCompletionSource(ctx: CompletionContext): CompletionResult | null {
// Match a word that is at least 1 character, preceded by a word boundary
const mainLine = ctx.state.doc.lineAt(ctx.pos);
const line = mainLine.text;
const pos = ctx.pos - mainLine.from; // line-relative cursor offset // = cursor offset in the line
const ch = pos > 0 ? line[pos - 1] : "";

// ── Bracket context: `m[` — matrix-index skeleton ─────────────────────
if (ch === "[" && pos >= 2 && /\w/.test(line[pos - 2])) {
const type = resolveType(ctx, pos - 1);
if (type) {
// Vectors are indexed with dot-swizzles, not `[i]`; offering a swizzle
// char here would replace the `[` and produce `vx`. Only matrices get a
// bracket skeleton. (Scalars already return no members.)
if (/^[ib]?vec\d$/.test(type)) return null;
const options = membersForType(type);
// from covers the typed `[`, so the skeleton replaces it.
if (options.length > 0) return { from: ctx.pos - 1, options, validFor: /^\[?\d*$/ };
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
}
return null;
}

// ── Dot-member context: `q.` — swizzle accessors ──────────────────────
if (ch === "." && pos >= 2 && /\w/.test(line[pos - 2])) {
const type = resolveType(ctx, pos - 1);
if (type) {
const options = membersForType(type);
if (options.length > 0) return { from: ctx.pos, options, validFor: /^\w*$/ };
}
return null;
}

// ── Word context: keyword / function / type / constructor ──────────────
const word = ctx.matchBefore(/\w+/);
if (!word) return null;
// Don't suggest when the user hasn't typed at least 1 char OR
// cursor is in the middle of a word and wasn't explicitly triggered
if (word.from === word.to && !ctx.explicit) return null;

const typed = word.text.toLowerCase();
const keywordOpts = GLSL_COMPLETIONS.filter(c =>
c.label.toLowerCase().startsWith(typed)
);

const options = GLSL_COMPLETIONS.filter(c =>
const ctorOpts = constructorCompletions().filter(c =>
c.label.toLowerCase().startsWith(typed)
);

const options = [...keywordOpts, ...ctorOpts];
if (options.length === 0) return null;

return {
from: word.from,
options,
validFor: /^\w*$/,
};
return { from: word.from, options, validFor: /^\w*$/ };
}
Loading
Loading