import { CodeEditor, Selection } from "rkgk/code-editor.js"; import { SaveData } from "rkgk/framework.js"; import { builtInPresets } from "rkgk/brush-box.js"; import { ResizeHandle } from "rkgk/resize-handle.js"; import { BrushPreview } from "rkgk/brush-preview.js"; import { BrushCostGauges } from "rkgk/brush-cost.js"; export class BrushEditor extends HTMLElement { saveData = new SaveData("brushEditor"); constructor() { super(); } connectedCallback() { this.saveData.attachToElement(this); const defaultBrush = builtInPresets[0]; this.editorContainer = this.appendChild(document.createElement("div")); this.editorContainer.classList.add("editor"); this.nameEditor = this.editorContainer.appendChild(document.createElement("input")); this.nameEditor.value = this.saveData.get("name", defaultBrush.name); this.nameEditor.classList.add("name"); this.nameEditor.addEventListener("input", () => { this.saveData.set("name", this.nameEditor.value); this.dispatchEvent( Object.assign( new Event(".nameChanged", { newName: this.nameEditor.value, }), ), ); }); this.codeEditor = this.editorContainer.appendChild( new CodeEditor([ { className: "layer-syntax", render: (code, element) => this.#renderSyntax(code, element), }, { className: "layer-error-squiggles", render: (code, element) => this.#renderErrorSquiggles(code, element), }, ]), ); this.codeEditor.setCode( // NOTE(localStorage): Migration from old storage key. this.saveData.get("code") ?? localStorage.getItem("rkgk.brushEditor.code") ?? defaultBrush.code, ); this.codeEditor.addEventListener(".codeChanged", (event) => { this.saveData.set("code", event.newCode); this.dispatchEvent( Object.assign(new Event(".codeChanged"), { newCode: event.newCode, }), ); }); this.output = document.createElement("output"); this.appendChild( new ResizeHandle({ direction: "horizontal", inverse: true, targetElement: this, targetProperty: "--brush-preview-height", initSize: 192, minSize: 64, }), ).classList.add("always-visible"); this.appendChild(this.output); this.ok = this.output.appendChild(document.createElement("div")); this.ok.classList.add("ok"); this.brushPreview = this.ok.appendChild(new BrushPreview()); this.brushCostGauges = this.ok.appendChild(new BrushCostGauges()); this.errors = this.output.appendChild(document.createElement("pre")); this.errors.classList.add("errors"); // NOTE(localStorage): Migration from old storage key. localStorage.removeItem("rkgk.brushEditor.code"); } get name() { return this.nameEditor.value; } setName(newName) { this.nameEditor.value = newName; this.saveData.set("name", this.nameEditor.value); } get code() { return this.codeEditor.code; } setCode(newCode) { this.codeEditor.setCode(newCode); } resetErrors() { this.output.dataset.state = "ok"; this.errors.textContent = ""; } async updatePreview(haku, { getStats }) { let previewResult = await this.brushPreview.renderBrush(haku); this.brushCostGauges.update(getStats()); if (previewResult.status == "error") { this.renderHakuResult(previewResult.result); } } renderHakuResult(result) { this.resetErrors(); this.errorSquiggles = null; if (result.status != "error") { // We need to request a rebuild if there's no error to remove any squiggles that may be // left over from the error state. this.codeEditor.renderLayer("layer-error-squiggles"); return; } this.output.dataset.state = "error"; if (result.errorKind == "diagnostics") { this.codeEditor.rebuildLineMap(); this.errorSquiggles = this.#computeErrorSquiggles( this.codeEditor.lineMap, result.diagnostics, ); this.codeEditor.renderLayer("layer-error-squiggles"); this.errors.textContent = result.diagnostics .map( (diagnostic) => `${diagnostic.start}..${diagnostic.end}: ${diagnostic.message}`, ) .join("\n"); } else if (result.errorKind == "plain") { this.errors.textContent = result.message; } else if (result.errorKind == "exception") { let renderer = new ErrorException(result); let squiggles = renderer.prepareSquiggles(); this.codeEditor.rebuildLineMap(); this.errorSquiggles = this.#computeErrorSquiggles( this.codeEditor.lineMap, squiggles.diagnostics, ); this.codeEditor.renderLayer("layer-error-squiggles"); renderer.addEventListener(".functionNameMouseEnter", (event) => { squiggles.highlightSegment(event.frameIndex); }); renderer.addEventListener(".functionNameMouseLeave", () => { squiggles.highlightSegment(null); }); renderer.addEventListener(".functionNameClick", (event) => { let span = event.frame.span; this.codeEditor.textArea.focus(); this.codeEditor.setSelection(new Selection(span.start, span.end)); }); this.errors.replaceChildren(); this.errors.appendChild(renderer); } else { console.warn(`unknown error kind: ${result.errorKind}`); this.errors.textContent = "(unknown error kind)"; } } #computeErrorSquiggles(lineMap, diagnostics) { // This is an extremely inefficient algorithm. // If we had better control of drawing (I'm talking: letter per letter, with a shader!) // this could be done lot more efficiently. But alas, HTML giveth, HTML taketh away. // The first step is to determine per-line spans. // Since we render squiggles by line, it makes the most sense to split the work up to a // line by line basis. let rawLineSpans = new Map(); for (let diagnostic of diagnostics) { let firstLine = lineMap.lineIndexAt(diagnostic.start); let lastLine = lineMap.lineIndexAt(diagnostic.end); for (let i = firstLine; i <= lastLine; ++i) { let bounds = lineMap.get(i); let start = i == firstLine ? diagnostic.start - bounds.start : 0; let end = i == lastLine ? diagnostic.end - bounds.start : bounds.end; if (!rawLineSpans.has(i)) { rawLineSpans.set(i, []); } let onThisLine = rawLineSpans.get(i); onThisLine.push({ start, end, diagnostic }); } } // Once we have the diagnostics subdivided per line, it's time to determine the _boundaries_ // where diagnostics need to appear. // Later we will turn those boundaries into spans, and assign them appropriate classes. let segmentedLines = new Map(); for (let [line, spans] of rawLineSpans) { let lineBounds = lineMap.get(line); let spanBorderSet = new Set([0]); for (let { start, end } of spans) { spanBorderSet.add(start); spanBorderSet.add(end); } spanBorderSet.add(lineBounds.end - lineBounds.start); let spanBorders = Array.from(spanBorderSet).sort((a, b) => a - b); let segments = []; let previous = 0; for (let i = 1; i < spanBorders.length; ++i) { segments.push({ start: previous, end: spanBorders[i], diagnostics: [] }); previous = spanBorders[i]; } for (let span of spans) { for (let segment of segments) { if (segment.start >= span.start && segment.end <= span.end) { segment.diagnostics.push(span.diagnostic); } } } segmentedLines.set(line, segments); } return segmentedLines; } #renderErrorSquiggles(lines, element) { if (this.errorSquiggles == null) return; for (let i = 0; i < lines.lineBounds.length; ++i) { let lineBounds = lines.lineBounds[i]; let lineElement = element.appendChild(document.createElement("span")); lineElement.classList.add("line"); let segments = this.errorSquiggles.get(i); if (segments != null) { for (let segment of segments) { let text = (lineBounds.substring + " ").substring(segment.start, segment.end); if (segment.diagnostics.length == 0) { lineElement.append(text); } else { let spanElement = lineElement.appendChild(document.createElement("span")); spanElement.classList.add("squiggle", "squiggle-error"); spanElement.textContent = text; for (let diagnostic of segment.diagnostics) { diagnostic.customizeSegment?.(diagnostic, spanElement); } } } } else { lineElement.textContent = lineBounds.substring; } } } #renderSyntax(lines, element) { // TODO: Syntax highlighting. // Right now we just render a layer of black text to have the text visible in the code editor. element.textContent = lines.string; } } customElements.define("rkgk-brush-editor", BrushEditor); class ErrorException extends HTMLElement { constructor(result) { super(); this.result = result; } prepareSquiggles() { let diagnostics = []; let segments = []; const customizeSegment = (diagnostic, segment) => { // The control flow here is kind of spaghetti, but basically this fills out `segments` // as the squiggles are being rendered. // Once squiggles are rendered, you're free to call highlightSegment(i). segments.push({ frameIndex: diagnostic.frameIndex, colorIndex: diagnostic.colorIndex, segment, }); if (diagnostic.colorIndex != null) { segment.classList.add("squiggle-colored"); segment.style.setProperty("--color-index", diagnostic.colorIndex); } }; // Iterate in reverse to let uppermost stack frames "win" and overwrite bottommost stack // frames' colouring. for (let i = this.result.stackTrace.length - 1; i >= 0; i--) { let frame = this.result.stackTrace[i]; if (frame.span != null) { diagnostics.push({ start: frame.span.start, end: frame.span.end, customizeSegment, frameIndex: i, colorIndex: i / this.result.stackTrace.length, }); } } return { diagnostics, highlightSegment(frameIndex) { let justHighlighted = new Set(); for (let entry of segments.values()) { let segment = entry.segment; if (entry.frameIndex == frameIndex) { segment.classList.add("squiggle-highlighted"); // This logic exists so that a specific segment can display on top of // another one if it's highlighted. segment.style.setProperty("--highlight-color-index", entry.colorIndex); justHighlighted.add(segment); } else if (!justHighlighted.has(segment)) { segment.classList.remove("squiggle-highlighted"); } } }, }; } connectedCallback() { let message = this.appendChild(document.createElement("p")); message.classList.add("message"); message.textContent = this.result.message; let stackTrace = this.appendChild(document.createElement("ol")); stackTrace.classList.add("stack-trace"); for (let i = 0; i < this.result.stackTrace.length; ++i) { let frame = this.result.stackTrace[i]; let line = stackTrace.appendChild(document.createElement("li")); let [_, name, lambdasString] = frame.functionName.match(/([^λ]+)(λ*)/); let inCaption = line.appendChild(document.createElement("span")); inCaption.classList.add("in"); inCaption.innerText = "in "; let functionName = line.appendChild(document.createElement("button")); functionName.classList.add("function-name"); functionName.disabled = true; functionName.innerText = name; if (name == "(brush)") { functionName.classList.add("function-name-brush"); functionName.title = "Top-level brush code"; } let lambdas = functionName.appendChild(document.createElement("abbr")); lambdas.classList.add("lambdas"); lambdas.textContent = lambdasString; lambdas.title = "λ - unnamed function\n" + "Symbolizes functions that were not given a name.\n" + "Each additional λ is one level of nesting inside another function."; if (frame.isSystem) { line.append(" "); let system = line.appendChild(document.createElement("abbr")); system.classList.add("system"); system.innerText = "(built-in)"; system.title = "This function is built into rakugaki and does not exist in your brush's source code, so you can't see it in the editor."; } if (frame.span != null) { functionName.disabled = false; functionName.classList.add("source-link"); functionName.style.setProperty("--color-index", i / this.result.stackTrace.length); functionName.addEventListener("mouseenter", () => { this.dispatchEvent( Object.assign(new Event(".functionNameMouseEnter"), { frameIndex: i, frame, }), ); }); functionName.addEventListener("mouseleave", () => { this.dispatchEvent( Object.assign(new Event(".functionNameMouseLeave"), { frameIndex: i, frame, }), ); }); functionName.addEventListener("click", () => { this.dispatchEvent( Object.assign(new Event(".functionNameClick"), { frameIndex: i, frame, }), ); }); } } } } customElements.define("rkgk-brush-editor-error-exception", ErrorException);