Skip to content

Commit 16c4dee

Browse files
authored
fix(editor): improve wrapped line indentation and keep text wrap off by default (#2507)
1 parent c2c308a commit 16c4dee

38 files changed

Lines changed: 665 additions & 47 deletions

src/cm/indentedLineWrapping.ts

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
import type { Extension } from "@codemirror/state";
2+
import { EditorState, RangeSetBuilder } from "@codemirror/state";
3+
import {
4+
Decoration,
5+
type DecorationSet,
6+
EditorView,
7+
ViewPlugin,
8+
type ViewUpdate,
9+
} from "@codemirror/view";
10+
11+
export interface IndentedLineWrappingConfig {
12+
/** How continuation rows are indented relative to the logical line. */
13+
mode?: WrappingIndentMode;
14+
/**
15+
* Absolute maximum continuation indent in visual columns. The effective
16+
* maximum is also limited to half of the editor's visible width.
17+
*/
18+
maxIndentColumns?: number;
19+
}
20+
21+
export type WrappingIndentMode = "none" | "same" | "indent" | "deepIndent";
22+
23+
export const DEFAULT_WRAPPING_INDENT_MODE: WrappingIndentMode = "indent";
24+
export const DEFAULT_MAX_WRAP_INDENT_COLUMNS = 40;
25+
26+
const WRAPPED_LINE_CLASS = "cm-indented-line-wrap";
27+
const WRAP_INDENT_PROPERTY = "--cm-wrap-indent";
28+
const WRAP_INDENT_NEGATIVE_PROPERTY = "--cm-wrap-indent-negative";
29+
30+
type DecorationCache = Map<number, Decoration>;
31+
32+
function getTabSize(state: EditorState): number {
33+
const tabSize = state.facet(EditorState.tabSize);
34+
return Number.isFinite(tabSize) && tabSize > 0
35+
? Math.max(1, Math.trunc(tabSize))
36+
: 4;
37+
}
38+
39+
function normalizeMaxIndentColumns(value: number | undefined): number {
40+
if (!Number.isFinite(value) || Number(value) <= 0) {
41+
return DEFAULT_MAX_WRAP_INDENT_COLUMNS;
42+
}
43+
return Math.max(1, Math.trunc(Number(value)));
44+
}
45+
46+
function normalizeWrappingIndentMode(
47+
value: WrappingIndentMode | undefined,
48+
): WrappingIndentMode {
49+
switch (value) {
50+
case "none":
51+
case "same":
52+
case "indent":
53+
case "deepIndent":
54+
return value;
55+
default:
56+
return DEFAULT_WRAPPING_INDENT_MODE;
57+
}
58+
}
59+
60+
/**
61+
* Count leading indentation in visual columns. The scan stops as soon as the
62+
* configured cap is reached, keeping even pathological lines cheap to process.
63+
*/
64+
export function getWrapIndentColumns(
65+
line: string,
66+
tabSize: number,
67+
maxIndentColumns = DEFAULT_MAX_WRAP_INDENT_COLUMNS,
68+
): number {
69+
const normalizedTabSize =
70+
Number.isFinite(tabSize) && tabSize > 0
71+
? Math.max(1, Math.trunc(tabSize))
72+
: 4;
73+
const normalizedMax = normalizeMaxIndentColumns(maxIndentColumns);
74+
let columns = 0;
75+
76+
for (let index = 0; index < line.length; index++) {
77+
const character = line.charCodeAt(index);
78+
if (character === 32) {
79+
columns++;
80+
} else if (character === 9) {
81+
columns += normalizedTabSize - (columns % normalizedTabSize);
82+
} else {
83+
break;
84+
}
85+
86+
if (columns >= normalizedMax) return normalizedMax;
87+
}
88+
89+
return columns;
90+
}
91+
92+
/**
93+
* Calculate the visual column where wrapped continuation rows should start.
94+
* `indent` mirrors Ace and VS Code by adding one indentation step, while
95+
* `deepIndent` adds two.
96+
*/
97+
export function getContinuationIndentColumns(
98+
line: string,
99+
tabSize: number,
100+
mode: WrappingIndentMode = DEFAULT_WRAPPING_INDENT_MODE,
101+
maxIndentColumns = DEFAULT_MAX_WRAP_INDENT_COLUMNS,
102+
): number {
103+
if (!line.length || mode === "none") return 0;
104+
105+
const normalizedTabSize =
106+
Number.isFinite(tabSize) && tabSize > 0
107+
? Math.max(1, Math.trunc(tabSize))
108+
: 4;
109+
const normalizedMax = normalizeMaxIndentColumns(maxIndentColumns);
110+
const baseIndent = getWrapIndentColumns(
111+
line,
112+
normalizedTabSize,
113+
normalizedMax,
114+
);
115+
const additionalIndent =
116+
mode === "deepIndent"
117+
? normalizedTabSize * 2
118+
: mode === "indent"
119+
? normalizedTabSize
120+
: 0;
121+
122+
return Math.min(baseIndent + additionalIndent, normalizedMax);
123+
}
124+
125+
function getEffectiveMaxIndentColumns(
126+
view: EditorView,
127+
absoluteMaximum: number,
128+
): number {
129+
const tabSize = getTabSize(view.state);
130+
const characterWidth = view.defaultCharacterWidth;
131+
const contentWidth = view.scrollDOM.clientWidth || view.dom.clientWidth;
132+
133+
if (
134+
!Number.isFinite(characterWidth) ||
135+
characterWidth <= 0 ||
136+
!Number.isFinite(contentWidth) ||
137+
contentWidth <= 0
138+
) {
139+
return absoluteMaximum;
140+
}
141+
142+
const halfVisibleColumns = Math.floor(contentWidth / characterWidth / 2);
143+
return Math.max(
144+
1,
145+
Math.min(absoluteMaximum, Math.max(tabSize, halfVisibleColumns)),
146+
);
147+
}
148+
149+
function getLineDecoration(
150+
columns: number,
151+
cache: DecorationCache,
152+
): Decoration {
153+
let decoration = cache.get(columns);
154+
if (decoration) return decoration;
155+
156+
decoration = Decoration.line({
157+
attributes: {
158+
class: WRAPPED_LINE_CLASS,
159+
style: `${WRAP_INDENT_PROPERTY}:${columns}ch;${WRAP_INDENT_NEGATIVE_PROPERTY}:-${columns}ch`,
160+
},
161+
});
162+
cache.set(columns, decoration);
163+
return decoration;
164+
}
165+
166+
function buildDecorations(
167+
view: EditorView,
168+
mode: WrappingIndentMode,
169+
maxIndentColumns: number,
170+
cache: DecorationCache,
171+
): DecorationSet {
172+
const builder = new RangeSetBuilder<Decoration>();
173+
const { doc } = view.state;
174+
const tabSize = getTabSize(view.state);
175+
let lastProcessedLine = 0;
176+
177+
for (const { from, to } of view.visibleRanges) {
178+
const firstLine = doc.lineAt(from);
179+
const lastLine = doc.lineAt(Math.max(from, to - 1));
180+
181+
for (
182+
let lineNumber = firstLine.number;
183+
lineNumber <= lastLine.number;
184+
lineNumber++
185+
) {
186+
if (lineNumber <= lastProcessedLine) continue;
187+
lastProcessedLine = lineNumber;
188+
189+
const line = doc.line(lineNumber);
190+
const columns = getContinuationIndentColumns(
191+
line.text,
192+
tabSize,
193+
mode,
194+
maxIndentColumns,
195+
);
196+
if (columns === 0) continue;
197+
198+
builder.add(line.from, line.from, getLineDecoration(columns, cache));
199+
}
200+
}
201+
202+
return builder.finish();
203+
}
204+
205+
function createIndentedLineWrappingPlugin(
206+
mode: WrappingIndentMode,
207+
absoluteMaxIndentColumns: number,
208+
): Extension {
209+
return ViewPlugin.fromClass(
210+
class {
211+
decorations: DecorationSet;
212+
decorationCache: DecorationCache = new Map();
213+
lastTabSize: number;
214+
lastMaxIndentColumns: number;
215+
216+
constructor(view: EditorView) {
217+
this.lastTabSize = getTabSize(view.state);
218+
this.lastMaxIndentColumns = getEffectiveMaxIndentColumns(
219+
view,
220+
absoluteMaxIndentColumns,
221+
);
222+
this.decorations = buildDecorations(
223+
view,
224+
mode,
225+
this.lastMaxIndentColumns,
226+
this.decorationCache,
227+
);
228+
}
229+
230+
update(update: ViewUpdate): void {
231+
const tabSize = getTabSize(update.state);
232+
const maxIndentColumns = getEffectiveMaxIndentColumns(
233+
update.view,
234+
absoluteMaxIndentColumns,
235+
);
236+
if (
237+
!update.docChanged &&
238+
!update.viewportChanged &&
239+
tabSize === this.lastTabSize &&
240+
maxIndentColumns === this.lastMaxIndentColumns
241+
) {
242+
return;
243+
}
244+
245+
this.lastTabSize = tabSize;
246+
this.lastMaxIndentColumns = maxIndentColumns;
247+
this.decorations = buildDecorations(
248+
update.view,
249+
mode,
250+
this.lastMaxIndentColumns,
251+
this.decorationCache,
252+
);
253+
}
254+
255+
destroy(): void {
256+
this.decorationCache.clear();
257+
}
258+
},
259+
{
260+
decorations: (value) => value.decorations,
261+
},
262+
);
263+
}
264+
265+
// The zero-width pseudo-element shifts only the first visual row back over the
266+
// added padding. Unlike `text-indent`, it does not change CodeMirror's global
267+
// line-origin measurement, which is also used by rectangular selections.
268+
const indentedLineWrappingTheme = EditorView.baseTheme({
269+
[`.cm-line.${WRAPPED_LINE_CLASS}`]: {
270+
paddingInlineStart: `calc(6px + var(${WRAP_INDENT_PROPERTY}))`,
271+
},
272+
[`.cm-line.${WRAPPED_LINE_CLASS}::before`]: {
273+
content: '""',
274+
display: "inline-block",
275+
width: "0",
276+
height: "0",
277+
marginInlineStart: `var(${WRAP_INDENT_NEGATIVE_PROPERTY})`,
278+
},
279+
});
280+
281+
/**
282+
* Enable regular CodeMirror line wrapping and align continuation rows with the
283+
* logical line's leading indentation.
284+
*/
285+
export function indentedLineWrapping(
286+
config: IndentedLineWrappingConfig = {},
287+
): Extension {
288+
const mode = normalizeWrappingIndentMode(config.mode);
289+
const maxIndentColumns = normalizeMaxIndentColumns(config.maxIndentColumns);
290+
if (mode === "none") return EditorView.lineWrapping;
291+
292+
return [
293+
EditorView.lineWrapping,
294+
createIndentedLineWrappingPlugin(mode, maxIndentColumns),
295+
indentedLineWrappingTheme,
296+
];
297+
}
298+
299+
export default indentedLineWrapping;

src/lang/ar-ye.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,5 +831,11 @@
831831
"managed": "Managed",
832832
"acode service": "Acode Service",
833833
"info-terminal-show-scrollbar": "Show the xterm scrollbar beside the terminal.",
834-
"terminal:show scrollbar": "Show Scrollbar"
834+
"terminal:show scrollbar": "Show Scrollbar",
835+
"wrapped line indent": "Wrapped line indent",
836+
"same": "Same",
837+
"indent": "Indent",
838+
"deep indent": "Deep indent",
839+
"settings-info-editor-wrapping-indent": "Choose how far visually wrapped continuation lines are indented.",
840+
"settings-info-editor-wrap-performance-warning": "May reduce editor performance with very long lines or large files."
835841
}

src/lang/be-by.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,5 +831,11 @@
831831
"managed": "Managed",
832832
"acode service": "Acode Service",
833833
"info-terminal-show-scrollbar": "Show the xterm scrollbar beside the terminal.",
834-
"terminal:show scrollbar": "Show Scrollbar"
834+
"terminal:show scrollbar": "Show Scrollbar",
835+
"wrapped line indent": "Wrapped line indent",
836+
"same": "Same",
837+
"indent": "Indent",
838+
"deep indent": "Deep indent",
839+
"settings-info-editor-wrapping-indent": "Choose how far visually wrapped continuation lines are indented.",
840+
"settings-info-editor-wrap-performance-warning": "May reduce editor performance with very long lines or large files."
835841
}

src/lang/bn-bd.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,5 +831,11 @@
831831
"managed": "Managed",
832832
"acode service": "Acode Service",
833833
"info-terminal-show-scrollbar": "Show the xterm scrollbar beside the terminal.",
834-
"terminal:show scrollbar": "Show Scrollbar"
834+
"terminal:show scrollbar": "Show Scrollbar",
835+
"wrapped line indent": "Wrapped line indent",
836+
"same": "Same",
837+
"indent": "Indent",
838+
"deep indent": "Deep indent",
839+
"settings-info-editor-wrapping-indent": "Choose how far visually wrapped continuation lines are indented.",
840+
"settings-info-editor-wrap-performance-warning": "May reduce editor performance with very long lines or large files."
835841
}

src/lang/cs-cz.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,5 +831,11 @@
831831
"managed": "Managed",
832832
"acode service": "Acode Service",
833833
"info-terminal-show-scrollbar": "Show the xterm scrollbar beside the terminal.",
834-
"terminal:show scrollbar": "Show Scrollbar"
834+
"terminal:show scrollbar": "Show Scrollbar",
835+
"wrapped line indent": "Wrapped line indent",
836+
"same": "Same",
837+
"indent": "Indent",
838+
"deep indent": "Deep indent",
839+
"settings-info-editor-wrapping-indent": "Choose how far visually wrapped continuation lines are indented.",
840+
"settings-info-editor-wrap-performance-warning": "May reduce editor performance with very long lines or large files."
835841
}

src/lang/de-de.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,5 +831,11 @@
831831
"managed": "Managed",
832832
"acode service": "Acode Service",
833833
"info-terminal-show-scrollbar": "Show the xterm scrollbar beside the terminal.",
834-
"terminal:show scrollbar": "Show Scrollbar"
834+
"terminal:show scrollbar": "Show Scrollbar",
835+
"wrapped line indent": "Wrapped line indent",
836+
"same": "Same",
837+
"indent": "Indent",
838+
"deep indent": "Deep indent",
839+
"settings-info-editor-wrapping-indent": "Choose how far visually wrapped continuation lines are indented.",
840+
"settings-info-editor-wrap-performance-warning": "May reduce editor performance with very long lines or large files."
835841
}

src/lang/en-us.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,5 +831,11 @@
831831
"diff sec": "{sec}s ago",
832832
"command": "Command",
833833
"managed": "Managed",
834-
"acode service": "Acode Service"
834+
"acode service": "Acode Service",
835+
"wrapped line indent": "Wrapped line indent",
836+
"same": "Same",
837+
"indent": "Indent",
838+
"deep indent": "Deep indent",
839+
"settings-info-editor-wrapping-indent": "Choose how far visually wrapped continuation lines are indented.",
840+
"settings-info-editor-wrap-performance-warning": "May reduce editor performance with very long lines or large files."
835841
}

src/lang/es-sv.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,5 +831,11 @@
831831
"managed": "Managed",
832832
"acode service": "Acode Service",
833833
"info-terminal-show-scrollbar": "Show the xterm scrollbar beside the terminal.",
834-
"terminal:show scrollbar": "Show Scrollbar"
834+
"terminal:show scrollbar": "Show Scrollbar",
835+
"wrapped line indent": "Wrapped line indent",
836+
"same": "Same",
837+
"indent": "Indent",
838+
"deep indent": "Deep indent",
839+
"settings-info-editor-wrapping-indent": "Choose how far visually wrapped continuation lines are indented.",
840+
"settings-info-editor-wrap-performance-warning": "May reduce editor performance with very long lines or large files."
835841
}

0 commit comments

Comments
 (0)