fix(editor): keep indent guides straight and drop the column-0 guide (#1525)

Indent guides are drawn as one 1px strip per level in the `::after` overlay (so selections and mark backgrounds don't cover them), stay straight across wrapped and empty lines, and start at the first nested level: the column-0 guide is gone. Code lens rows keep the guides of the line they annotate.

Co-authored-by: lleviathe <[email protected]>
This commit is contained in:
lleviathe authored and GitHub committed 2026-11-08 09:08:38 +08:00
1 parent fba893b36d
commit 9673ccc687
4 files changed
+157 -36

No files matched your search

+24 -3
View File
@@ -4,7 +4,9 @@
*/
import { EditorState, RangeSetBuilder, type Extension } from "@codemirror/state";
import { Decoration, EditorView, WidgetType, type DecorationSet } from "@codemirror/view";
import { getIndentUnit } from "@codemirror/language";
import { codeLenses, type CodeLens } from "@/editor/api";
import { guideVars } from "./indentGuides";
/** Groups lenses by line start, ordered by line then `order`. Exported for tests. */
export function groupLenses(
@@ -66,6 +68,7 @@ class LensWidget extends WidgetType {
constructor(
readonly lenses: CodeLens[],
readonly indent: number,
readonly indentUnit: number,
) {
super();
}
@@ -74,6 +77,8 @@ class LensWidget extends WidgetType {
return (
this.indent +
"\u0000" +
this.indentUnit +
"\u0000" +
this.lenses.map((l) => `${l.icon ?? ""}|${l.title}|${l.tooltip ?? ""}|${l.run ? 1 : 0}`).join("\u0001")
);
}
@@ -97,6 +102,15 @@ class LensWidget extends WidgetType {
private fill(dom: HTMLElement, view: EditorView) {
dom.textContent = "";
dom.style.paddingLeft = `${Math.round(this.indent * view.defaultCharacterWidth) + 6}px`;
// The guides of the annotated line continue through this row while indent guides are on.
const guides = guideVars(Math.floor(this.indent / this.indentUnit), this.indentUnit);
if (guides) {
dom.style.setProperty("--openidea-guide-images", guides.images);
dom.style.setProperty("--openidea-guide-positions", guides.positions);
} else {
dom.style.removeProperty("--openidea-guide-images");
dom.style.removeProperty("--openidea-guide-positions");
}
dom.setAttribute("role", "group");
dom.setAttribute("aria-label", "Code lens");
this.lenses.forEach((lens, i) => {
@@ -154,7 +168,11 @@ function buildDecorations(state: EditorState): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (const group of groupLenses(state, lenses)) {
const line = state.doc.lineAt(group.lineFrom);
const widget = new LensWidget(group.lenses, indentColumns(line.text, state.tabSize));
const widget = new LensWidget(
group.lenses,
indentColumns(line.text, state.tabSize),
Math.max(1, getIndentUnit(state)),
);
builder.add(line.from, line.from, Decoration.widget({ widget, block: true, side: -1 }));
}
return builder.finish();
@@ -166,8 +184,6 @@ const lensTheme = EditorView.theme({
alignItems: "center",
gap: "6px",
height: "16px",
fontFamily: "var(--font-ui)",
fontSize: "var(--fs-tiny)",
color: "var(--fg-muted)",
WebkitUserSelect: "none",
userSelect: "none",
@@ -184,6 +200,11 @@ const lensTheme = EditorView.theme({
font: "inherit",
color: "inherit",
},
// The row keeps the editor font so its `ch`-based indent guides line up with the code.
".cm-openidea-lensItem, .cm-openidea-lensSep": {
fontFamily: "var(--font-ui)",
fontSize: "var(--fs-tiny)",
},
".cm-openidea-lensAction": { cursor: "pointer" },
".cm-openidea-lensAction:hover": { color: "var(--accent)", textDecoration: "underline" },
".cm-openidea-lensSep": { color: "var(--fg-faint)" },
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it } from "vitest";
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { indentUnit } from "@codemirror/language";
import { codeLenses } from "@/editor/api";
import { codeLensExtension } from "./codeLens";
import { guideVars, indentGuides } from "./indentGuides";
const SOURCE = [
"fn parse() {",
" for x in y {",
" if a {",
" push();",
"",
" push();",
" }",
" }",
"}",
].join("\n");
let view: EditorView | undefined;
afterEach(() => {
view?.destroy();
view = undefined;
});
function mount(doc: string, extensions: Extension[] = []) {
view = new EditorView({
state: EditorState.create({ doc, extensions: [indentUnit.of(" "), indentGuides, ...extensions] }),
parent: document.body,
});
return view;
}
const line = (v: EditorView, n: number) => v.contentDOM.querySelectorAll<HTMLElement>(".cm-line")[n]!;
describe("indent guides", () => {
it("draws one strip per enclosing block, without a column-0 guide (IDEA)", () => {
expect(guideVars(0, 4)).toBeNull();
expect(guideVars(1, 4)).toBeNull();
expect(guideVars(3, 4)).toEqual({
images: "var(--openidea-guide), var(--openidea-guide)",
positions: "calc(6px + 4ch) 0, calc(6px + 8ch) 0",
});
});
it("positions each guide the same way on lines of different depth", () => {
const v = mount(SOURCE);
expect(line(v, 1).classList.contains("cm-openidea-guides")).toBe(false);
expect(line(v, 2).style.getPropertyValue("--openidea-guide-positions")).toBe("calc(6px + 4ch) 0");
expect(line(v, 3).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(6px + 4ch) 0, calc(6px + 8ch) 0",
);
});
it("keeps guides through blank lines inside a block", () => {
const v = mount(SOURCE);
expect(line(v, 4).style.getPropertyValue("--openidea-guide-positions")).toBe(
"calc(6px + 4ch) 0, calc(6px + 8ch) 0",
);
});
it("continues the guides of an annotated line through its code lens row", () => {
const doc = "mod a {\n impl B {\n fn c() {}\n }\n}";
const v = mount(doc, [
codeLensExtension,
codeLenses.of([
{ pos: doc.indexOf("fn c"), title: "2 usages" },
{ pos: doc.indexOf("impl"), title: "1 usage" },
]),
]);
const [implLens, fnLens] = [...v.contentDOM.querySelectorAll<HTMLElement>(".cm-openidea-lens")];
expect(v.contentDOM.classList.contains("cm-openidea-guidesOn")).toBe(true);
expect(fnLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("calc(6px + 4ch) 0");
expect(implLens!.style.getPropertyValue("--openidea-guide-positions")).toBe("");
});
});
+49 -26
View File
@@ -1,23 +1,41 @@
/**
* Indent guides drawn as a repeating background on each line's indentation
* (cheap: one line decoration per visible line, no extra DOM). Blank lines
* inherit the smaller level of their neighbours so guides stay continuous.
* Indent guides as in IDEA: a 1px line at the indent column of every enclosing
* block except top-level ones (IDEA draws no guide at column 0). Each line's
* indentation carries one background strip per guide (one line decoration per
* visible line, no extra DOM); a strip sits at the same offset on every line,
* so a guide stays straight across lines of different depth. Blank lines take
* the smaller level of their neighbours and code lens rows the level of the
* line they annotate (`guideVars`), so guides stay continuous.
*/
import { RangeSetBuilder, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { getIndentUnit } from "@codemirror/language";
const cache = new Map<string, Decoration>();
/**
* Values of `--openidea-guide-images` / `--openidea-guide-positions` for an
* indentation `levels` deep (in units of `step` columns), or null when it has
* no guides. `.cm-openidea-guides` lines and code lens rows turn them into
* backgrounds (theme.ts).
*/
export function guideVars(levels: number, step: number): { images: string; positions: string } | null {
if (levels < 2) return null;
const images: string[] = [];
const positions: string[] = [];
for (let level = 1; level < levels; level++) {
images.push("var(--openidea-guide)");
positions.push(`calc(6px + ${level * step}ch) 0`);
}
return { images: images.join(", "), positions: positions.join(", ") };
}
const cache = new Map<string, Decoration | null>();
function guideDeco(levels: number, step: number) {
const key = `${levels}:${step}`;
let d = cache.get(key);
if (!d) {
d = Decoration.line({
attributes: {
class: "cm-openidea-guides",
style: `--openidea-guide-levels: ${levels}; --openidea-guide-step: ${step}`,
},
});
if (d === undefined) {
const vars = guideVars(levels, step);
const style = vars && `--openidea-guide-images: ${vars.images}; --openidea-guide-positions: ${vars.positions}`;
d = style ? Decoration.line({ attributes: { class: "cm-openidea-guides", style } }) : null;
cache.set(key, d);
}
return d;
@@ -61,7 +79,8 @@ function build(view: EditorView): DecorationSet {
} else {
levels = Math.floor(cols / step);
}
if (levels > 0) b.add(line.from, line.from, guideDeco(levels, step));
const deco = guideDeco(levels, step);
if (deco) b.add(line.from, line.from, deco);
}
pos = line.to + 1;
}
@@ -69,16 +88,20 @@ function build(view: EditorView): DecorationSet {
return b.finish();
}
export const indentGuides: Extension = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = build(view);
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged || u.startState.tabSize !== u.state.tabSize)
this.decorations = build(u.view);
}
},
{ decorations: (v) => v.decorations },
);
export const indentGuides: Extension = [
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = build(view);
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged || u.startState.tabSize !== u.state.tabSize)
this.decorations = build(u.view);
}
},
{ decorations: (v) => v.decorations },
),
// Lets code lens rows draw the guides too, only while guides are on.
EditorView.contentAttributes.of({ class: "cm-openidea-guidesOn" }),
];
+7 -7
View File
@@ -133,6 +133,7 @@ export const openideaTheme = EditorView.theme({
"--editor-selection-inactive":
"var(--bg-editor-selection-inactive, color-mix(in srgb, var(--accent) 14%, transparent))",
"--editor-indent-guide": "var(--border-indent-guide, var(--border))",
"--openidea-guide": "linear-gradient(var(--editor-indent-guide), var(--editor-indent-guide))",
...findLocals,
"--editor-brace": "var(--bg-matched-brace, color-mix(in srgb, var(--accent) 22%, transparent))",
"--editor-fold-chip": "var(--bg-fold-placeholder, color-mix(in srgb, var(--fg-muted) 14%, transparent))",
@@ -274,19 +275,18 @@ export const openideaTheme = EditorView.theme({
},
".cm-foldPlaceholder:hover": { backgroundColor: "color-mix(in srgb, var(--fg-muted) 24%, transparent)" },
// Indent guides (cm/indentGuides.ts)
// Indent guides (cm/indentGuides.ts): one 1px strip per guide, positioned per guide.
// Above the text so a selection, or a mark's background, never covers part of a guide.
".cm-openidea-guides": { position: "relative" },
".cm-openidea-guides::after": {
".cm-openidea-guides, .cm-openidea-guidesOn .cm-openidea-lens": { position: "relative" },
".cm-openidea-guides::after, .cm-openidea-guidesOn .cm-openidea-lens::after": {
content: '""',
position: "absolute",
inset: "0",
pointerEvents: "none",
backgroundImage:
"repeating-linear-gradient(to right, var(--editor-indent-guide) 0 1px, transparent 1px calc(var(--openidea-guide-step) * 1ch))",
backgroundSize: "calc(var(--openidea-guide-levels) * var(--openidea-guide-step) * 1ch) 100%",
backgroundImage: "var(--openidea-guide-images, none)",
backgroundPosition: "var(--openidea-guide-positions, 0 0)",
backgroundSize: "1px 100%",
backgroundRepeat: "no-repeat",
backgroundPosition: "6px 0",
},
// Rainbow brackets (cm/rainbowBrackets.ts); `*` too: the syntax highlighter's span sits inside and would win.