diff --git a/static/base.css b/static/base.css index e830dd7..5ae5a4b 100644 --- a/static/base.css +++ b/static/base.css @@ -14,6 +14,8 @@ --font-body: "Atkinson Hyperlegible", sans-serif; --font-monospace: "Iosevka Hyperlegible", monospace; + --line-height: 1.5; + --line-height-em: 1.5em; } /* Reset */ @@ -21,7 +23,7 @@ body { margin: 0; color: var(--color-text); - line-height: 1.5; + line-height: var(--line-height); } /* Fonts */ @@ -37,7 +39,7 @@ button, textarea, input { pre, code, textarea { font-family: var(--font-monospace); - line-height: 1.5; + line-height: var(--line-height); } /* Buttons */ diff --git a/static/brush-editor.js b/static/brush-editor.js index f90361f..2b36258 100644 --- a/static/brush-editor.js +++ b/static/brush-editor.js @@ -1,4 +1,4 @@ -import { CodeEditor } from "rkgk/code-editor.js"; +import { CodeEditor, getLineStart } from "rkgk/code-editor.js"; const defaultBrush = ` -- This is your brush. @@ -16,7 +16,14 @@ export class BrushEditor extends HTMLElement { connectedCallback() { this.classList.add("rkgk-panel"); - this.codeEditor = this.appendChild(new CodeEditor()); + this.codeEditor = this.appendChild( + new CodeEditor([ + { + className: "layer-error-squiggles", + render: (code, element) => this.#renderErrorSquiggles(code, element), + }, + ]), + ); this.codeEditor.setCode(localStorage.getItem("rkgk.brushEditor.code") ?? defaultBrush); this.codeEditor.addEventListener(".codeChanged", (event) => { localStorage.setItem("rkgk.brushEditor.code", event.newCode); @@ -46,15 +53,25 @@ export class BrushEditor extends HTMLElement { renderHakuResult(phase, result) { this.resetErrors(); + this.errorSquiggles = null; - if (result.status != "error") return; + 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.errorHeader.textContent = `${phase} failed`; if (result.errorKind == "diagnostics") { - // This is kind of wooden; I'd prefer if the error spans were rendered inline in text, - // but I haven't integrated anything for syntax highlighting yet that would let me - // do that. + this.codeEditor.rebuildLineMap(); + this.errorSquiggles = this.#computeErrorSquiggles( + this.codeEditor.lineMap, + result.diagnostics, + ); + this.codeEditor.renderLayer("layer-error-squiggles"); + this.errorArea.textContent = result.diagnostics .map( (diagnostic) => `${diagnostic.start}..${diagnostic.end}: ${diagnostic.message}`, @@ -70,6 +87,96 @@ export class BrushEditor extends HTMLElement { this.errorHeader.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.start); + 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; + } + } + } else { + lineElement.textContent = lineBounds.substring; + } + } + } } customElements.define("rkgk-brush-editor", BrushEditor); diff --git a/static/canvas-renderer.js b/static/canvas-renderer.js index 9e48807..48b7551 100644 --- a/static/canvas-renderer.js +++ b/static/canvas-renderer.js @@ -431,14 +431,16 @@ class CanvasRenderer extends HTMLElement { } async #zoomingBehaviour() { - while (true) { - let event = await listen([this, "wheel"]); - - // TODO: Touchpad zoom - this.viewport.zoomIn(event.deltaY > 0 ? -1 : 1); - this.sendViewportUpdate(); - this.dispatchEvent(new Event(".viewportUpdateEnd")); - } + this.addEventListener( + "wheel", + (event) => { + // TODO: Touchpad zoom + this.viewport.zoomIn(event.deltaY > 0 ? -1 : 1); + this.sendViewportUpdate(); + this.dispatchEvent(new Event(".viewportUpdateEnd")); + }, + { bubbling: false }, + ); } async #paintingBehaviour() { diff --git a/static/code-editor.js b/static/code-editor.js index f22a197..4e1b4e6 100644 --- a/static/code-editor.js +++ b/static/code-editor.js @@ -1,6 +1,8 @@ export class CodeEditor extends HTMLElement { - constructor() { + constructor(layers) { super(); + + this.layers = layers; } connectedCallback() { @@ -9,6 +11,16 @@ export class CodeEditor extends HTMLElement { this.layerGutter = this.appendChild(document.createElement("pre")); this.layerGutter.classList.add("layer", "layer-gutter"); + this.userLayers = []; + for (let layer of this.layers) { + let element = this.appendChild(document.createElement("pre")); + element.classList.add("layer", layer.className); + this.userLayers.push({ + def: layer, + element, + }); + } + this.textArea = this.appendChild(document.createElement("textarea")); this.textArea.spellcheck = false; this.textArea.rows = 1; @@ -66,26 +78,6 @@ export class CodeEditor extends HTMLElement { new ResizeObserver(() => this.#resizeTextArea()).observe(this.textArea); } - getSelection() { - // NOTE: We only support one selection, because multiple selections are only - // supported by Firefox. - - if (document.activeElement != this.textArea) return null; - - if (this.textArea.selectionDirection == "forward") { - return new Selection(this.textArea.selectionStart, this.textArea.selectionEnd); - } else { - return new Selection(this.textArea.selectionEnd, this.textArea.selectionStart); - } - } - - setSelection(selection) { - this.textArea.selectionDirection = - selection.anchor < selection.cursor ? "forward" : "backward"; - this.textArea.selectionStart = selection.start; - this.textArea.selectionEnd = selection.end; - } - #resizeTextArea() { this.textArea.style.height = ""; this.textArea.style.height = `${this.textArea.scrollHeight}px`; @@ -93,17 +85,36 @@ export class CodeEditor extends HTMLElement { // Layers + rebuildLineMap() { + this.lineMap = new LineMap(this.code); + } + #renderLayers() { + this.rebuildLineMap(); + this.#renderGutter(); + for (let userLayer of this.userLayers) { + userLayer.element.replaceChildren(); + userLayer.def.render(this.lineMap, userLayer.element); + } } #renderGutter() { this.layerGutter.replaceChildren(); - for (let line of this.code.split("\n")) { + for (let lineBounds of this.lineMap.lineBounds) { let lineElement = this.layerGutter.appendChild(document.createElement("span")); lineElement.classList.add("line"); - lineElement.textContent = line; + lineElement.textContent = lineBounds.substring; + } + } + + renderLayer(className) { + for (let userLayer of this.userLayers) { + if (userLayer.def.className == className) { + userLayer.element.replaceChildren(); + userLayer.def.render(this.lineMap, userLayer.element); + } } } @@ -131,6 +142,26 @@ export class CodeEditor extends HTMLElement { }); } + getSelection() { + // NOTE: We only support one selection, because multiple selections are only + // supported by Firefox. + + if (document.activeElement != this.textArea) return null; + + if (this.textArea.selectionDirection == "forward") { + return new Selection(this.textArea.selectionStart, this.textArea.selectionEnd); + } else { + return new Selection(this.textArea.selectionEnd, this.textArea.selectionStart); + } + } + + setSelection(selection) { + this.textArea.selectionDirection = + selection.anchor < selection.cursor ? "forward" : "backward"; + this.textArea.selectionStart = selection.start; + this.textArea.selectionEnd = selection.end; + } + replace(selection, text) { let left = this.code.substring(0, selection.start); let right = this.code.substring(selection.end); @@ -286,7 +317,7 @@ export class CodeEditor extends HTMLElement { customElements.define("rkgk-code-editor", CodeEditor); -class Selection { +export class Selection { constructor(anchor, cursor) { this.anchor = anchor; this.cursor = cursor; @@ -317,7 +348,7 @@ class Selection { } } -function getLineStart(string, position) { +export function getLineStart(string, position) { do { --position; } while (string.charAt(position) != "\n" && position > 0); @@ -325,16 +356,16 @@ function getLineStart(string, position) { return position; } -function getLineEnd(string, position) { +export function getLineEnd(string, position) { while (string.charAt(position) != "\n" && position < string.length) ++position; return position + 1; } -function getPositionInLine(string, position) { +export function getPositionInLine(string, position) { return position - getLineStart(string, position); } -function countSpaces(string, position) { +export function countSpaces(string, position) { let count = 0; while (string.charAt(position) == " ") { ++count; @@ -342,3 +373,53 @@ function countSpaces(string, position) { } return count; } + +export class LineMap { + constructor(string) { + // This simplifies the algorithm below a bit. + string += "\n"; + + this.string = string; + this.lineBounds = []; + + let start = 0; + for (let i = 0; i < string.length; ++i) { + if (string.charAt(i) == "\n") { + let substring = string.substring(start, i); + this.lineBounds.push({ start, end: i, substring }); + start = i + 1; + } + } + if (start < string.length) { + this.lineBounds.push({ start, end: string.length, substring: string.substring(start) }); + } + } + + get(lineIndex) { + return this.lineBounds[lineIndex]; + } + + lineIndexAt(position) { + // Ported from the Rust 1.81 standard library binary search. + // I was too lazy to come up with the algorithm myself. Sorry to disappoint. + + let size = this.lineBounds.length; + let left = 0; + let right = size; + while (left < right) { + let mid = (left + size / 2) | 0; + + let isLess = this.lineBounds[mid].start < position; + let isEqual = this.lineBounds[mid].start == position; + left = isLess && !isEqual ? mid + 1 : left; + right = !isLess && !isEqual ? mid : right; + if (isEqual) { + return mid; + } + + size = right - left; + } + + return left - 1; + } +} diff --git a/static/index.css b/static/index.css index 748c093..0817b26 100644 --- a/static/index.css +++ b/static/index.css @@ -193,6 +193,8 @@ rkgk-code-editor { &>.line { flex-shrink: 0; white-space: pre-wrap; + + min-height: var(--line-height-em); } } @@ -225,7 +227,27 @@ rkgk-code-editor { } } } - + + &>.layer:not(.layer-gutter) { + margin-left: var(--gutter-width); + width: calc(100% - var(--gutter-width)); + } + + &>.layer-error-squiggles { + color: transparent; + + &>.line { + &>.squiggle { + text-decoration: underline wavy black; + text-decoration-skip-ink: none; + } + + &>.squiggle-error { + text-decoration-color: var(--color-error); + } + } + } + &>textarea { display: block; width: calc(100% - var(--gutter-width));