From cb8d07f85327e88b14e279ed75ee8f0eaa14ac76 Mon Sep 17 00:00:00 2001 From: sidney-Hung Date: Fri, 9 Oct 2026 01:02:16 +0800 Subject: [PATCH] Draw indent guides in the code editor A candidate writing nested loops or branches in the plain textarea has no visual cue for which block a line belongs to, and Python makes that the difference between correct and broken code. The highlighter wraps each level of a line's leading whitespace in a guide, so the guides share the overlay's layout and scroll instead of needing a layer of their own. The level width is measured from the buffer, because the starters indent by two in JavaScript and by four elsewhere; the cost is that a buffer mixing both widths gets guides at the narrower one. --- tests/browser/editor-font-size.test.js | 41 ++++++ tests/browser/indent-guides.test.js | 194 +++++++++++++++++++++++++ web/highlight.js | 33 ++++- web/indent-guides.js | 168 +++++++++++++++++++++ web/interview.js | 28 +++- web/styles.css | 15 ++ 6 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 tests/browser/indent-guides.test.js create mode 100644 web/indent-guides.js diff --git a/tests/browser/editor-font-size.test.js b/tests/browser/editor-font-size.test.js index 2db00e3e..3be802f3 100644 --- a/tests/browser/editor-font-size.test.js +++ b/tests/browser/editor-font-size.test.js @@ -181,3 +181,44 @@ test("a stored size the page does not offer falls back to the default", async (t await page.close(); } }); + +// The repaint waits for the next frame and a scroll event does not, so Enter +// at the bottom scrolls the textarea while the overlay still holds the old +// text: its scrollTop clamps to the old height, and every row would sit one +// above its code until something scrolled again. Headless Chromium does not +// scroll a textarea to its caret, so the scroll a real browser makes is made +// here, between the input and the frame that paints it. +test("a scroll before the repaint leaves the overlay on its rows", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage(); + try { + await page.goto(`${base}/interview.html`, { + waitUntil: "domcontentloaded", + }); + await openEditor(page); + await fillLongBuffer(page); + for (let line = 0; line < 3; line += 1) { + const scroll = await page.evaluate( + () => + new Promise((settled) => { + const editor = document.querySelector("#editor"); + editor.value += "\n"; + editor.dispatchEvent(new Event("input", { bubbles: true })); + editor.scrollTop = editor.scrollHeight; + requestAnimationFrame(() => + requestAnimationFrame(() => + settled( + ["#editor", "#editor-highlight", "#editor-lines"].map( + (id) => document.querySelector(id).scrollTop, + ), + ), + ), + ); + }), + ); + assert.deepEqual(scroll.slice(1), [scroll[0], scroll[0]], `line ${line}`); + } + } finally { + await page.close(); + } +}); diff --git a/tests/browser/indent-guides.test.js b/tests/browser/indent-guides.test.js new file mode 100644 index 00000000..0fdddce2 --- /dev/null +++ b/tests/browser/indent-guides.test.js @@ -0,0 +1,194 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { highlight } from "../../web/highlight.js"; +import { detectWidth, indentGuides } from "../../web/indent-guides.js"; +import { tokenize } from "../../web/tokenizer.js"; + +const textOf = (html) => + html + .replace(/<[^>]+>/g, "") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("&", "&"); + +/// The overlay the editor paints: highlighted code with its guides in place. +const paint = (code, language = "javascript") => { + const { tokens } = tokenize(code, language); + return highlight(code, language, [], tokens, indentGuides(code, tokens)); +}; + +/// Guides per line, so a test reads as a picture of the code. +const depths = (code, language) => + paint(code, language) + .split("\n") + .map((line) => line.split('class="indent-guide"').length - 1); + +const widthOf = (code, language = "javascript") => { + const { tokens } = tokenize(code, language); + let offset = 0; + const lines = code.split("\n").map((line) => { + const whitespace = line.match(/^[ \t]*/)[0]; + const start = offset; + offset += line.length + 1; + return { start, whitespace, blank: whitespace.length === line.length }; + }); + return detectWidth(lines, tokens); +}; + +test("each full level of indentation gets one guide", () => { + assert.deepEqual( + depths("def f(n):\n if n:\n return 1\n return 0", "python"), + [0, 1, 2, 1], + ); +}); + +// The case review found: at a fixed width of four the `if` body went +// unmarked and `x();` got the function's guide instead of its own. +test("two-space code gets a guide per two columns", () => { + assert.deepEqual( + depths("function f() {\n if (a) {\n x();\n }\n}"), + [0, 1, 2, 1, 0], + ); +}); + +// A doc comment's ` * ` lines sit one column in from `/**`, and nearly every +// starter has one; counting them would measure a width of 1. +test("a doc comment does not vote on the width", () => { + const starter = [ + "/**", + " * @param {number[]} nums", + " * @return {number}", + " */", + "var f = function (nums) {", + " for (const n of nums) {", + " if (n) {", + " return n;", + " }", + " }", + "};", + ].join("\n"); + assert.equal(widthOf(starter), 2); + assert.deepEqual(depths(starter), [0, 0, 0, 0, 0, 1, 2, 3, 2, 1, 0]); +}); + +// Most JavaScript starters, whose only indented line is a comment. +test("a line that opens a comment is indented like code", () => { + const starter = + "/**\n * @param {string} s\n * @return {boolean}\n */\n" + + "function isValid(s) {\n // Think out loud as you go!\n}\n"; + assert.equal(widthOf(starter), 2); +}); + +test("a step of one is alignment, not a level", () => { + assert.equal(widthOf("a(b,\n c);\nif (x) {\n y;\n}"), 4); +}); + +test("a buffer with nothing indented measures the editor's own width", () => { + assert.equal(widthOf("x;\ny;"), 4); + assert.equal(widthOf(""), 4); +}); + +test("a tie takes the narrower width", () => { + assert.equal(widthOf("a {\n b {\n c;\n }\n}"), 2); +}); + +test("a tab on a blank line in a two-space block does not break the line", () => { + const code = "a {\n b {\n c {\n x;\n\t\n y;\n }\n }\n}"; + assert.equal(depths(code)[4], 3); +}); + +test("a tab and four spaces land on the same guide", () => { + assert.deepEqual(depths("{\n\tx;\n \ty;\n z;\n}"), [0, 1, 1, 1, 0]); +}); + +test("a partial level still gets its guide", () => { + assert.deepEqual( + depths("a:\n b\n c:\n d\n e", "python"), + [0, 1, 1, 2, 2], + ); +}); + +test("a code line seven columns in gets guides at 0 and 4", () => { + const code = "a {\n b {\n x;\n }\n c;\n}"; + assert.equal(widthOf(code), 4); + assert.equal(depths(code)[4], 2); + assert.equal(textOf(paint(code)), code); +}); + +/// Line 3 of a block whose lines 2 and 4 are two levels deep, at width 4. +const deepBlankRow = (blank) => { + const code = `a {\n b {\n x;\n${blank}\n y;\n }\n}`; + return { depth: depths(code)[3], text: textOf(paint(code)).split("\n")[3] }; +}; + +// Each reaches the second guide's column differently: not at all, partway +// through the first level, and partway through the second. +test("a blank line between two deep lines gets both guides at any length", () => { + for (const blank of ["", " ", " "]) { + assert.equal(deepBlankRow(blank).depth, 2, JSON.stringify(blank)); + } + // Seven columns already reach the second guide, so nothing is added; three + // do not, so the line is padded out to it. + assert.equal(deepBlankRow(" ").text, " "); + assert.equal(deepBlankRow(" ").text.length, 8); +}); + +test("a blank line takes the shallower of its neighbours", () => { + assert.deepEqual( + depths("def f():\n a = 1\n\n return a\n\nprint(f())", "python"), + [0, 1, 1, 1, 0, 0], + ); + assert.deepEqual(depths("\n\n x\n\ny"), [0, 0, 1, 0, 0]); +}); + +/// The guides on line 3 of a two-level block whose line 3 is `blank` and whose +/// line 4 is `next`, so each case below reads as the one row it is about. +const blankRow = (blank, next) => + depths(`function f() {\n if (a) {\n x();\n${blank}\n${next}\n}`)[3]; + +test("an empty line inside a block keeps the block's guides", () => { + assert.equal(blankRow("", " y();\n }"), 2); +}); + +test("an empty line after a block stops the inner guide", () => { + assert.equal(blankRow("", " }"), 1); +}); + +// Enter at the end of a block auto-indents the new line to the block's depth, +// with the shallower closer below it; until something is typed on it, it is +// blank, and it used to take the closer's depth instead of the caret's. +test("an auto-indented line at the end of a block keeps its own depth", () => { + assert.equal(blankRow(" ", " }"), 2); +}); + +test("stray whitespace draws no guide deeper than the code around it", () => { + assert.deepEqual(depths("x;\n \ny;"), [0, 0, 0]); +}); + +// The overlay has to put every character where the textarea does, so the +// guides wrap a code line's own whitespace and add nothing to it. +test("code lines keep their text, escaped, around the guides", () => { + const code = 'if (a < b) {\n\tlog("&");\n \t}\n // \n d;'; + assert.equal(textOf(paint(code)), code); + assert.doesNotMatch(paint(code), /|/); +}); + +test("guides inside a string or comment do not break its color", () => { + const html = paint('def f():\n s = """\n x\n """', "python"); + assert.match( + html, + /("){3}\n {4}<\/span>/, + ); +}); + +test("a blank line is never narrower than its own whitespace", () => { + const html = paint("{\n x\n \n y\n}"); + assert.equal(textOf(html).split("\n")[2].length, 10); +}); + +test("a trailing newline keeps the final empty row", () => { + assert.ok(paint("x\n").endsWith("\n\n")); +}); diff --git a/web/highlight.js b/web/highlight.js index b71f3592..6031ab5b 100644 --- a/web/highlight.js +++ b/web/highlight.js @@ -263,17 +263,46 @@ function highlightCode(code, spec, renderSlice) { /// Returns HTML for code. Every branch escapes candidate text. /// Supplied token ranges let callers reuse an existing classification. -export function highlight(code, language, brackets = [], tokens) { +/// +/// `indentation` is indentGuides()'s runs, each replacing a line's leading +/// whitespace with its guides. A run is whitespace only, so it never holds a +/// bracket and never meets a token or identifier boundary; one that would +/// straddle a slice is left as plain text rather than split. +export function highlight( + code, + language, + brackets = [], + tokens, + indentation = [], +) { const selected = Object.hasOwn(LANGUAGES, language) ? language : "javascript"; const spec = LANGUAGES[selected]; + // Both are in buffer order, and slices are rendered in buffer order, so one + // cursor into the runs serves every slice. + let run = 0; const renderSlice = (start, end) => { let text = ""; for (const index of brackets ?? []) { if (index < start || index >= end) continue; - text += escapeHtml(code.slice(start, index)); + text += renderPlain(start, index); text += `${escapeHtml(code[index])}`; start = index + 1; } + return text + renderPlain(start, end); + }; + const renderPlain = (start, end) => { + let text = ""; + while (run < indentation.length && indentation[run].start < start) run += 1; + while ( + run < indentation.length && + indentation[run].start < end && + indentation[run].end <= end + ) { + const { start: from, end: to, html } = indentation[run]; + text += escapeHtml(code.slice(start, from)) + html; + start = to; + run += 1; + } return text + escapeHtml(code.slice(start, end)); }; let html = ""; diff --git a/web/indent-guides.js b/web/indent-guides.js new file mode 100644 index 00000000..668d8b05 --- /dev/null +++ b/web/indent-guides.js @@ -0,0 +1,168 @@ +/// Columns a tab advances to the next multiple of. Matches the `tab-size: 4` +/// the editor layers share, so a tab and four spaces land on the same column. +const TAB = 4; + +/// The width when the buffer has no indented block to measure: what editor.js +/// inserts for one level, so the first block a candidate types lines up. +const FALLBACK_WIDTH = 4; + +const advance = (column, character) => + character === "\t" ? (Math.floor(column / TAB) + 1) * TAB : column + 1; + +const columnsOf = (whitespace) => [...whitespace].reduce(advance, 0); + +/// Splits leading whitespace at every guide boundary. A boundary that falls +/// inside a tab moves to the tab's end, so a unit never cuts a character. +/// +/// With `partial`, whitespace that ends partway through a level makes one more, +/// narrower unit: its guide column is still inside the whitespace, and the +/// guide is drawn on the unit's left edge, so it looks the same as a full one. +/// Without it that remainder is left over as `rest` and gets no guide. +function guideUnits(whitespace, width, partial) { + const units = []; + let column = 0; + let start = 0; + for (let index = 0; index < whitespace.length; index += 1) { + column = advance(column, whitespace[index]); + if (column >= (units.length + 1) * width) { + units.push(whitespace.slice(start, index + 1)); + start = index + 1; + } + } + if (partial && start < whitespace.length) { + units.push(whitespace.slice(start)); + start = whitespace.length; + } + return { units, rest: whitespace.slice(start) }; +} + +/// For each line, whether it continues a comment or a string opened on an +/// earlier line. Its indentation is the text's alignment rather than code's: +/// a block comment's ` * ` body sits one column in from its opener. A line +/// that opens a comment or string is indented like code, and is not one. +function continuations(lines, tokens) { + let token = 0; + return lines.map(({ start, whitespace, blank }) => { + if (blank) return false; + const first = start + whitespace.length; + while (token < tokens.length && tokens[token].end <= first) token += 1; + const inside = tokens[token]; + return Boolean(inside && inside.kind !== "code" && inside.start < first); + }); +} + +const guide = (text) => `${text}`; + +/// The most common step between one code line's indentation and the next +/// deeper one, so 2-space and 4-space buffers each get a guide per level. +/// +/// A line that continues a comment or a string does not count, or every starter +/// with a doc comment would vote for a width of 1. A line that opens one does, +/// which matters because a starter's body is often nothing but a comment. A +/// step of 1 is ignored for the same reason wherever else it comes from, since +/// it is alignment rather than a block. A tie takes the smaller width, because +/// a level drawn twice is easier to read past than a level not drawn at all. +export function detectWidth(lines, tokens) { + const votes = new Map(); + const continues = continuations(lines, tokens); + let previous = null; + for (const [index, { whitespace, blank }] of lines.entries()) { + if (blank || continues[index]) continue; + const columns = columnsOf(whitespace); + const step = columns - (previous ?? columns); + if (step >= 2 && step <= 8) votes.set(step, (votes.get(step) ?? 0) + 1); + previous = columns; + } + let width = FALLBACK_WIDTH; + let best = 0; + for (const [step, count] of votes) { + if (count > best || (count === best && step < width)) { + width = step; + best = count; + } + } + return width; +} + +/// Returns the guides for `code` as runs for highlight(): one per line that +/// gets any, covering that line's leading whitespace `[start, end)` and holding +/// the HTML that replaces it, with a guide at every guide column inside it. +/// A level the whitespace ends partway through still gets its guide, except on +/// a line continuing a comment or string, whose ` * ` would otherwise carry +/// one beside it on every line of every doc comment. +/// +/// A blank line takes at least the shallower of its neighbours' depths, so a +/// guide runs unbroken through a blank line inside a block and stops at one +/// after it. Its own whitespace can take it deeper, up to the deeper +/// neighbour: Enter at the end of a block auto-indents a line whose closer +/// below is shallower, and that line keeps the depth the caret is at, while +/// stray spaces cannot draw guides past the code around them. A line whose +/// whitespace stops short of its last guide's column gets spaces added; they +/// end a line with nothing after them, so no character that follows moves. +export function indentGuides(code, tokens) { + let offset = 0; + const lines = code.split("\n").map((line) => { + const whitespace = line.match(/^[ \t]*/)[0]; + const start = offset; + offset += line.length + 1; + return { start, whitespace, blank: whitespace.length === line.length }; + }); + const width = detectWidth(lines, tokens); + const continues = continuations(lines, tokens); + lines.forEach((entry, index) => { + Object.assign( + entry, + guideUnits(entry.whitespace, width, !continues[index]), + ); + }); + + const depths = lines.map((entry) => + entry.blank ? null : entry.units.length, + ); + const below = new Array(lines.length).fill(0); + for (let index = lines.length - 1, next = 0; index >= 0; index -= 1) { + below[index] = next; + if (depths[index] !== null) next = depths[index]; + } + + const runs = []; + let above = 0; + lines.forEach((entry, index) => { + const end = entry.start + entry.whitespace.length; + if (!entry.blank) { + above = depths[index]; + if (entry.units.length) { + runs.push({ + start: entry.start, + end, + html: entry.units.map(guide).join("") + entry.rest, + }); + } + return; + } + const own = entry.units.length; + const depth = Math.max( + Math.min(above, below[index]), + Math.min(own, Math.max(above, below[index])), + ); + if (!depth) return; + let html; + if (depth <= own) { + html = + entry.units.slice(0, depth).map(guide).join("") + + entry.units.slice(depth).join("") + + entry.rest; + } else { + // Out to the column after its own last guide, then a full level each. + // A tab can cross two guide columns in one unit at a narrow width, so + // the line can already be past that column. + const columns = columnsOf(entry.whitespace); + html = + entry.units.map(guide).join("") + + " ".repeat(Math.max(0, own * width - columns)) + + guide(" ".repeat(width)).repeat(depth - own); + } + runs.push({ start: entry.start, end, html }); + }); + return runs; +} diff --git a/web/interview.js b/web/interview.js index 5935ee77..558637e5 100644 --- a/web/interview.js +++ b/web/interview.js @@ -9,6 +9,7 @@ import { videoTrackReady, } from "./audio-check.js"; import { highlight } from "./highlight.js"; +import { indentGuides } from "./indent-guides.js"; import { prepareLanguage } from "./syntax-parser.js"; import { tokenize } from "./tokenizer.js"; import { @@ -821,12 +822,7 @@ function bindEvents() { matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => applyEditorTheme(nodes.editorStack.dataset.theme), ); - // The overlay does not scroll on its own; it follows the textarea. - nodes.editor.addEventListener("scroll", () => { - nodes.editorHighlight.scrollTop = nodes.editor.scrollTop; - nodes.editorHighlight.scrollLeft = nodes.editor.scrollLeft; - nodes.editorLines.scrollTop = nodes.editor.scrollTop; - }); + nodes.editor.addEventListener("scroll", syncEditorScroll); document.addEventListener("selectionchange", () => { if (document.activeElement === nodes.editor) scheduleEditorPaint(); }); @@ -3247,6 +3243,14 @@ function scheduleEditorPaint() { }); } +/// The overlay and the gutter do not scroll on their own; they follow the +/// textarea. +function syncEditorScroll() { + nodes.editorHighlight.scrollTop = nodes.editor.scrollTop; + nodes.editorHighlight.scrollLeft = nodes.editor.scrollLeft; + nodes.editorLines.scrollTop = nodes.editor.scrollTop; +} + /// Repaints the syntax layer and the gutter behind the textarea. /// /// One read of the buffer rather than two, and the gutter is rebuilt only when @@ -3278,11 +3282,16 @@ function paintEditor(showMatch = document.activeElement === nodes.editor) { ) return; paintedEditor = { code, language: state.language, brackets }; + const classified = editorTokens(code, state.language); + // Kept with the tokens they are measured from, so a caret move that only + // shifts the bracket match reuses the guides instead of measuring again. + classified.guides ??= indentGuides(code, classified.tokens); nodes.editorHighlight.firstElementChild.innerHTML = highlight( code, state.language, brackets, - editorTokens(code, state.language).tokens, + classified.tokens, + classified.guides, ); const lines = code.split("\n").length; if (lines !== paintedLineCount) { @@ -3292,6 +3301,11 @@ function paintEditor(showMatch = document.activeElement === nodes.editor) { (_, index) => index + 1, ).join("\n"); } + // The paint is deferred to the next frame, and a scroll event can run + // before it: Enter at the bottom of a long buffer scrolls the textarea + // while the layers still hold the old text, so their scrollTop clamps to the + // old height and every row sits one above its code until the next scroll. + syncEditorScroll(); } /// Sets one custom property that the textarea, the highlight overlay and the diff --git a/web/styles.css b/web/styles.css index ac5412ec..267c19d0 100644 --- a/web/styles.css +++ b/web/styles.css @@ -33,6 +33,9 @@ light gutter background. */ --code-gutter-ink: var(--muted); --code-gutter-border: var(--hairline); + /* Indent guides sit under the code, so they stay fainter than it in both + themes. */ + --code-guide: #404040; --tok-keyword: #569cd6; --tok-string: #ce9178; --tok-comment: #6a9955; @@ -997,6 +1000,7 @@ p { --code-match-outline: #0000ff; --code-gutter-ink: #595959; --code-gutter-border: #dddddd; + --code-guide: #d3d3d3; --tok-keyword: #0000ff; --tok-string: #a31515; --tok-comment: #008000; @@ -1021,6 +1025,7 @@ p { --code-match-outline: #0000ff; --code-gutter-ink: #595959; --code-gutter-border: #dddddd; + --code-guide: #d3d3d3; --tok-keyword: #0000ff; --tok-string: #a31515; --tok-comment: #008000; @@ -1054,6 +1059,16 @@ p { overflow: hidden; } +/* An inline box paints its line only over the font's content area, which is + shorter than the 1.5 line height, so stacked guides would read as dashes. + An inline block is a full line tall and, aligned to the top, adds nothing + to the line box. */ +.indent-guide { + display: inline-block; + vertical-align: top; + box-shadow: inset 1px 0 0 var(--code-guide); +} + /* The user-agent stylesheet gives its own monospace family, which beats the inherited stack and paints the overlay in a different font from the textarea, so the text drifts from the caret line by line. */