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. */