From 9673ccc6876a9ff9acdebfa0e408a3ca38d9fe1c Mon Sep 17 00:00:00 2001 From: lleviathe <95321986+lleviathe@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:08:38 +0400 Subject: [PATCH] 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 <95321986+lleviathe@users.noreply.github.com> --- src/editor/core/cm/codeLens.ts | 27 ++++++++- src/editor/core/cm/indentGuides.test.ts | 77 +++++++++++++++++++++++++ src/editor/core/cm/indentGuides.ts | 75 +++++++++++++++--------- src/editor/core/cm/theme.ts | 14 ++--- 4 files changed, 157 insertions(+), 36 deletions(-) create mode 100644 src/editor/core/cm/indentGuides.test.ts diff --git a/src/editor/core/cm/codeLens.ts b/src/editor/core/cm/codeLens.ts index c2e58852a..f6165221a 100644 --- a/src/editor/core/cm/codeLens.ts +++ b/src/editor/core/cm/codeLens.ts @@ -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(); 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)" }, diff --git a/src/editor/core/cm/indentGuides.test.ts b/src/editor/core/cm/indentGuides.test.ts new file mode 100644 index 000000000..701527dbf --- /dev/null +++ b/src/editor/core/cm/indentGuides.test.ts @@ -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(".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(".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(""); + }); +}); diff --git a/src/editor/core/cm/indentGuides.ts b/src/editor/core/cm/indentGuides.ts index 7ff452d96..3aee71016 100644 --- a/src/editor/core/cm/indentGuides.ts +++ b/src/editor/core/cm/indentGuides.ts @@ -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(); +/** + * 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(); 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" }), +]; diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 3ae068345..b60f2e2ea 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -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.