Skip to content

Commit a323229

Browse files
committed
rework the indent guides
1 parent be461a7 commit a323229

File tree

2 files changed

+95
-118
lines changed

2 files changed

+95
-118
lines changed

src/cm/indentGuides.ts

Lines changed: 81 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { syntaxTree } from "@codemirror/language";
1+
import { getIndentUnit, syntaxTree } from "@codemirror/language";
22
import type { Extension } from "@codemirror/state";
33
import { EditorState, RangeSetBuilder } from "@codemirror/state";
44
import {
@@ -7,7 +7,6 @@ import {
77
EditorView,
88
ViewPlugin,
99
type ViewUpdate,
10-
WidgetType,
1110
} from "@codemirror/view";
1211
import type { SyntaxNode } from "@lezer/common";
1312

@@ -26,11 +25,23 @@ const defaultConfig: Required<IndentGuidesConfig> = {
2625
hideOnBlankLines: false,
2726
};
2827

28+
const GUIDE_MARK_CLASS = "cm-indent-guides";
29+
2930
/**
3031
* Get the tab size from editor state
3132
*/
3233
function getTabSize(state: EditorState): number {
33-
return state.facet(EditorState.tabSize);
34+
const tabSize = state.facet(EditorState.tabSize);
35+
return Number.isFinite(tabSize) && tabSize > 0 ? tabSize : 4;
36+
}
37+
38+
/**
39+
* Resolve the indentation width used for guide spacing.
40+
*/
41+
function getIndentUnitColumns(state: EditorState): number {
42+
const width = getIndentUnit(state);
43+
if (Number.isFinite(width) && width > 0) return width;
44+
return getTabSize(state);
3445
}
3546

3647
/**
@@ -57,6 +68,21 @@ function isBlankLine(line: string): boolean {
5768
return /^\s*$/.test(line);
5869
}
5970

71+
/**
72+
* Count the leading indentation characters of a line.
73+
*/
74+
function getLeadingWhitespaceLength(line: string): number {
75+
let count = 0;
76+
for (const ch of line) {
77+
if (ch === " " || ch === "\t") {
78+
count++;
79+
continue;
80+
}
81+
break;
82+
}
83+
return count;
84+
}
85+
6086
/**
6187
* Node types that represent scope blocks in various languages
6288
*/
@@ -83,7 +109,6 @@ const SCOPE_NODE_TYPES = new Set([
83109
"Element",
84110
"SelfClosingTag",
85111
"RuleSet",
86-
"Block",
87112
"DeclarationList",
88113
"Body",
89114
"Suite",
@@ -114,15 +139,12 @@ function getActiveScope(
114139

115140
const tree = syntaxTree(state);
116141
if (!tree || tree.length === 0) {
117-
// No syntax tree available, fall back to indentation-based
118142
return getActiveScopeByIndentation(state, indentUnit);
119143
}
120144

121-
// Find the innermost scope node containing the cursor
122145
let scopeNode: SyntaxNode | null = null;
123146
let node: SyntaxNode | null = tree.resolveInner(cursorPos, 0);
124147

125-
// Walk up the tree to find a scope-defining node
126148
while (node) {
127149
if (SCOPE_NODE_TYPES.has(node.name)) {
128150
scopeNode = node;
@@ -135,12 +157,8 @@ function getActiveScope(
135157
return null;
136158
}
137159

138-
// Get the line range of this scope
139160
const startLine = state.doc.lineAt(scopeNode.from);
140161
const endLine = state.doc.lineAt(scopeNode.to);
141-
142-
// Calculate indent level from the first line of the scope's content
143-
// (usually the line after the opening bracket)
144162
let contentStartLine = startLine.number;
145163
if (startLine.number < endLine.number) {
146164
contentStartLine = startLine.number + 1;
@@ -149,7 +167,6 @@ function getActiveScope(
149167
const tabSize = getTabSize(state);
150168
let level = 0;
151169

152-
// Find the first non-blank line inside the scope to determine indent level
153170
for (let ln = contentStartLine; ln <= endLine.number; ln++) {
154171
const line = state.doc.line(ln);
155172
if (!isBlankLine(line.text)) {
@@ -228,56 +245,31 @@ function getActiveScopeByIndentation(
228245
return { level: cursorLevel, startLine, endLine };
229246
}
230247

231-
/**
232-
* Widget that renders indent guide lines
233-
*/
234-
class IndentGuidesWidget extends WidgetType {
235-
constructor(
236-
readonly levels: number,
237-
readonly indentUnit: number,
238-
readonly activeGuideIndex: number,
239-
readonly lineHeight: number,
240-
) {
241-
super();
242-
}
243-
244-
eq(other: IndentGuidesWidget): boolean {
245-
return (
246-
other.levels === this.levels &&
247-
other.indentUnit === this.indentUnit &&
248-
other.activeGuideIndex === this.activeGuideIndex &&
249-
other.lineHeight === this.lineHeight
250-
);
251-
}
252-
253-
toDOM(): HTMLElement {
254-
const container = document.createElement("span");
255-
container.className = "cm-indent-guides-wrapper";
256-
container.setAttribute("aria-hidden", "true");
257-
258-
const guidesContainer = document.createElement("span");
259-
guidesContainer.className = "cm-indent-guides";
260-
261-
for (let i = 0; i < this.levels; i++) {
262-
const guide = document.createElement("span");
263-
guide.className = "cm-indent-guide";
264-
guide.style.left = `${i * this.indentUnit}ch`;
265-
guide.style.height = `${this.lineHeight}px`;
266-
267-
if (i === this.activeGuideIndex) {
268-
guide.classList.add("cm-indent-guide-active");
269-
}
270-
271-
guidesContainer.appendChild(guide);
272-
}
273-
274-
container.appendChild(guidesContainer);
275-
return container;
248+
function buildGuideStyle(
249+
levels: number,
250+
guideStepPx: number,
251+
activeGuideIndex: number,
252+
): string {
253+
const images = [];
254+
const positions = [];
255+
const sizes = [];
256+
257+
for (let i = 0; i < levels; i++) {
258+
const color =
259+
i === activeGuideIndex
260+
? "var(--indent-guide-active-color)"
261+
: "var(--indent-guide-color)";
262+
images.push(`linear-gradient(${color}, ${color})`);
263+
positions.push(`${i * guideStepPx}px 0`);
264+
sizes.push("1px 100%");
276265
}
277266

278-
ignoreEvent(): boolean {
279-
return true;
280-
}
267+
return [
268+
`background-image:${images.join(",")}`,
269+
"background-repeat:no-repeat",
270+
`background-position:${positions.join(",")}`,
271+
`background-size:${sizes.join(",")}`,
272+
].join(";");
281273
}
282274

283275
/**
@@ -290,16 +282,13 @@ function buildDecorations(
290282
const builder = new RangeSetBuilder<Decoration>();
291283
const { state } = view;
292284
const tabSize = getTabSize(state);
293-
const indentUnit = tabSize;
285+
const indentUnit = getIndentUnitColumns(state);
286+
const guideStepPx = Math.max(view.defaultCharacterWidth * indentUnit, 1);
294287

295-
// Get active scope using syntax tree (or fallback to indentation)
296288
const activeScope = config.highlightActiveGuide
297289
? getActiveScope(view, indentUnit)
298290
: null;
299291

300-
const lineHeight = view.defaultLineHeight;
301-
302-
// Only process visible lines for performance
303292
for (const { from: blockFrom, to: blockTo } of view.visibleRanges) {
304293
const startLine = state.doc.lineAt(blockFrom);
305294
const endLine = state.doc.lineAt(blockTo);
@@ -314,34 +303,30 @@ function buildDecorations(
314303

315304
const indentColumns = getLineIndentation(lineText, tabSize);
316305
const levels = Math.floor(indentColumns / indentUnit);
317-
318-
if (levels > 0) {
319-
let activeGuideIndex = -1;
320-
321-
// Check if this line is in the active scope
322-
if (
323-
activeScope &&
324-
lineNum >= activeScope.startLine &&
325-
lineNum <= activeScope.endLine &&
326-
levels >= activeScope.level
327-
) {
328-
activeGuideIndex = activeScope.level - 1;
329-
}
330-
331-
const widget = new IndentGuidesWidget(
332-
levels,
333-
indentUnit,
334-
activeGuideIndex,
335-
lineHeight,
336-
);
337-
338-
const deco = Decoration.widget({
339-
widget,
340-
side: -1,
341-
});
342-
343-
builder.add(line.from, line.from, deco);
306+
if (levels <= 0) continue;
307+
const leadingWhitespaceLength = getLeadingWhitespaceLength(lineText);
308+
if (leadingWhitespaceLength <= 0) continue;
309+
310+
let activeGuideIndex = -1;
311+
if (
312+
activeScope &&
313+
lineNum >= activeScope.startLine &&
314+
lineNum <= activeScope.endLine &&
315+
levels >= activeScope.level
316+
) {
317+
activeGuideIndex = activeScope.level - 1;
344318
}
319+
320+
builder.add(
321+
line.from,
322+
line.from + leadingWhitespaceLength,
323+
Decoration.mark({
324+
attributes: {
325+
class: GUIDE_MARK_CLASS,
326+
style: buildGuideStyle(levels, guideStepPx, activeGuideIndex),
327+
},
328+
}),
329+
);
345330
}
346331
}
347332

@@ -366,7 +351,6 @@ function createIndentGuidesPlugin(
366351
}
367352

368353
update(update: ViewUpdate): void {
369-
// Only rebuild when necessary
370354
if (
371355
update.docChanged ||
372356
update.viewportChanged ||
@@ -384,34 +368,13 @@ function createIndentGuidesPlugin(
384368
}
385369

386370
/**
387-
* Theme for indent guides with subtle animation
371+
* Theme for indent guides.
372+
* Uses a single span around leading indentation instead of per-guide widgets.
388373
*/
389374
const indentGuidesTheme = EditorView.baseTheme({
390-
".cm-indent-guides-wrapper": {
391-
display: "inline",
392-
position: "relative",
393-
width: "0",
394-
height: "0",
395-
overflow: "visible",
396-
verticalAlign: "top",
397-
},
398375
".cm-indent-guides": {
399-
position: "absolute",
400-
top: "0",
401-
left: "0",
402-
height: "100%",
403-
pointerEvents: "none",
404-
zIndex: "0",
405-
},
406-
".cm-indent-guide": {
407-
position: "absolute",
408-
top: "0",
409-
width: "1px",
410-
background: "var(--indent-guide-color)",
411-
transition: "background 0.15s ease, opacity 0.15s ease",
412-
},
413-
".cm-indent-guide-active": {
414-
background: "var(--indent-guide-active-color)",
376+
display: "inline-block",
377+
verticalAlign: "top",
415378
},
416379
"&": {
417380
"--indent-guide-color": "rgba(128, 128, 128, 0.25)",

src/test/editor.tests.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
99
import { EditorSelection, EditorState } from "@codemirror/state";
1010
import { EditorView } from "@codemirror/view";
1111
import createBaseExtensions from "cm/baseExtensions";
12+
import indentGuides from "cm/indentGuides";
1213
import { getEdgeScrollDirections } from "cm/touchSelectionMenu";
1314
import { TestRunner } from "./tester";
1415

@@ -417,6 +418,19 @@ export async function runCodeMirrorTests(writeOutput) {
417418
});
418419
});
419420

421+
runner.test("Indent guides render as indentation spans", async (test) => {
422+
const doc = "function x() {\n if (true) {\n return 1;\n }\n}";
423+
await withEditor(test, async (view) => {
424+
const guideLine = view.dom.querySelector(".cm-indent-guides");
425+
const legacyWidget = view.dom.querySelector(".cm-indent-guides-wrapper");
426+
test.assert(guideLine != null, "Indent guide span should exist");
427+
test.assert(
428+
legacyWidget == null,
429+
"Indent guides should not create widget wrapper DOM",
430+
);
431+
}, doc, [indentGuides()]);
432+
});
433+
420434
runner.test("Focus and blur", async (test) => {
421435
await withEditor(test, async (view) => {
422436
view.focus();

0 commit comments

Comments
 (0)