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
41 changes: 41 additions & 0 deletions tests/browser/editor-font-size.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
194 changes: 194 additions & 0 deletions tests/browser/indent-guides.test.js
Original file line number Diff line number Diff line change
@@ -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("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&amp;", "&");

/// 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("<b>&</b>");\n \t}\n // <c>\n d;';
assert.equal(textOf(paint(code)), code);
assert.doesNotMatch(paint(code), /<b>|<c>/);
});

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,
/<span class="tok-string">(&quot;){3}\n<span class="indent-guide"> {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"));
});
33 changes: 31 additions & 2 deletions web/highlight.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 += `<span class="matching-bracket">${escapeHtml(code[index])}</span>`;
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 = "";
Expand Down
Loading