From a30461509ef0f00967adb107dda51914c5775eba Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:22:48 +0530 Subject: [PATCH 1/2] fix(editor): preserve indentation on soft-wrapped lines --- src/cm/indentedLineWrapping.ts | 181 ++++++++++++++++++++++++ src/lib/editorManager.js | 3 +- tests/unit/indentedLineWrapping.test.ts | 24 ++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 src/cm/indentedLineWrapping.ts create mode 100644 tests/unit/indentedLineWrapping.test.ts diff --git a/src/cm/indentedLineWrapping.ts b/src/cm/indentedLineWrapping.ts new file mode 100644 index 0000000000..4b86a5ff13 --- /dev/null +++ b/src/cm/indentedLineWrapping.ts @@ -0,0 +1,181 @@ +import { + EditorState, + type Extension, + RangeSetBuilder, + StateEffect, +} from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, +} from "@codemirror/view"; + +const wrapWidth = StateEffect.define(); + +/** Keep tab stops on the first visual row unchanged by the negative indent. */ +export function wrappedIndentColumns( + text: string, + tabSize: number, + limit: number, +): number { + // Unindented/minified lines need no tab scan, even when they are megabytes long. + if (limit <= 0 || (text[0] !== " " && text[0] !== "\t")) return 0; + const hasTabs = text.includes("\t"); + const step = hasTabs ? tabSize : 1; + const cap = Math.max(0, Math.floor(limit / step) * step); + let columns = 0; + for (const char of text) { + if (char === " ") columns++; + else if (char === "\t") columns += tabSize - (columns % tabSize); + else break; + if (columns >= cap) return cap; + } + return Math.min(cap, Math.ceil(columns / step) * step); +} + +interface CachedIndent { + text: string; + decoration: Decoration | null; +} + +function decorate( + view: EditorView, + limit: number, + previous: Map, +): { decorations: DecorationSet; cache: Map } { + const builder = new RangeSetBuilder(); + const tabSize = view.state.facet(EditorState.tabSize); + const cache = new Map(); + const styles = new Map(); + let lastLine = -1; + for (const { from, to } of view.visibleRanges) { + for (let pos = from; pos <= to; ) { + const line = view.state.doc.lineAt(pos); + if (line.from > lastLine) { + let entry = previous.get(line.from); + if (!entry || entry.text !== line.text) { + const columns = wrappedIndentColumns(line.text, tabSize, limit); + let decoration = columns ? styles.get(columns) : null; + if (columns && !decoration) { + decoration = Decoration.line({ + attributes: { + class: "cm-wrap-indent", + style: `--cm-wrap-indent: ${columns}ch`, + }, + }); + styles.set(columns, decoration); + } + entry = { text: line.text, decoration: decoration ?? null }; + } + cache.set(line.from, entry); + if (entry.decoration) + builder.add(line.from, line.from, entry.decoration); + lastLine = line.from; + } + pos = line.to + 1; + } + } + // Drop offscreen entries rather than retaining strings from the whole file. + return { decorations: builder.finish(), cache }; +} + +const plugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet = Decoration.none; + limit = 0; + cache = new Map(); + + constructor(view: EditorView) { + this.measure(view); + } + + measure(view: EditorView) { + view.requestMeasure({ + key: this, + read: () => + Math.max( + 0, + Math.floor( + (view.contentDOM.clientWidth - 8) / + view.defaultCharacterWidth / + 2, + ), + ), + write: (limit) => { + if (limit === this.limit) return; + // Measurement writes run inside CodeMirror's update. Dispatch only + // after it finishes, and ignore work queued by a removed plugin. + queueMicrotask(() => { + if (view.plugin(plugin) === this && limit !== this.limit) { + view.dispatch({ effects: wrapWidth.of(limit) }); + } + }); + }, + }); + } + + update(update: ViewUpdate) { + let changed = false; + for (const transaction of update.transactions) { + for (const effect of transaction.effects) { + if (effect.is(wrapWidth)) { + this.limit = effect.value; + changed = true; + } + } + } + const tabSizeChanged = + update.startState.facet(EditorState.tabSize) !== + update.state.facet(EditorState.tabSize); + if (changed || tabSizeChanged) { + this.cache.clear(); + } else if (update.docChanged) { + // Keep unchanged visible lines cached when earlier edits shift them. + this.cache = new Map( + Array.from(this.cache, ([pos, entry]) => [ + update.changes.mapPos(pos, -1), + entry, + ]), + ); + } + if ( + changed || + update.docChanged || + update.viewportChanged || + tabSizeChanged + ) { + const result = decorate(update.view, this.limit, this.cache); + this.decorations = result.decorations; + this.cache = result.cache; + } + if (update.geometryChanged) this.measure(update.view); + } + }, + { decorations: (value) => value.decorations }, +); + +/** + * Browser-native soft wrapping, with no widgets, replacement text, or input + * handlers. Line attributes leave CodeMirror's text/selection/composition DOM + * under its own control. `ch` tracks font changes without rounding tab stops. + * Mixed tab/space indents round up to a tab stop; oversized indents are capped + * at half the available columns so narrow panes still have room for content. + */ +export function indentedLineWrapping(): Extension { + return [ + EditorView.lineWrapping, + plugin, + EditorView.baseTheme({ + ".cm-line.cm-wrap-indent": { + paddingInlineStart: "calc(6px + var(--cm-wrap-indent))", + textIndent: "calc(-1 * var(--cm-wrap-indent))", + }, + ".cm-wrap-indent > *": { + // Inline-block indent guides and widgets must not inherit the indent. + textIndent: "0", + }, + }), + ]; +} diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 3ce9068465..d1b85514ee 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -78,6 +78,7 @@ import { restoreSelection, setScrollPosition, } from "cm/editorUtils"; +import { indentedLineWrapping } from "cm/indentedLineWrapping"; import indentGuides from "cm/indentGuides"; import { lineBreakMarker } from "cm/lineBreakMarker"; import quickToolsModifierInput from "cm/quickToolsModifierInput"; @@ -1033,7 +1034,7 @@ async function EditorManager($header, $body) { } function makeWrapExtension() { - if (appSettings?.value?.textWrap) return EditorView.lineWrapping; + if (appSettings?.value?.textWrap) return indentedLineWrapping(); return horizontalScrollPastEnd( Number(appSettings?.value?.leftMargin ?? 50), ); diff --git a/tests/unit/indentedLineWrapping.test.ts b/tests/unit/indentedLineWrapping.test.ts new file mode 100644 index 0000000000..62b2c98405 --- /dev/null +++ b/tests/unit/indentedLineWrapping.test.ts @@ -0,0 +1,24 @@ +import { wrappedIndentColumns } from "cm/indentedLineWrapping"; +import { describe, expect, it } from "vitest"; + +describe("wrapped line indentation", () => { + it("preserves space indentation and stops at content", () => { + expect(wrappedIndentColumns(" code ", 4, 40)).toBe(3); + expect(wrappedIndentColumns("code", 4, 40)).toBe(0); + expect(wrappedIndentColumns("", 4, 40)).toBe(0); + }); + it("counts tabs from their current column and rounds mixed indentation up", () => { + expect(wrappedIndentColumns(" \tcode", 4, 40)).toBe(4); + expect(wrappedIndentColumns(" \t code", 4, 40)).toBe(8); + expect(wrappedIndentColumns("\t code", 8, 40)).toBe(16); + }); + it("preserves tab stops even when a tab appears after the indentation", () => { + expect(wrappedIndentColumns(" key\tvalue", 4, 40)).toBe(4); + }); + it("caps deep indentation without introducing fractional tab stops", () => { + expect(wrappedIndentColumns(" ".repeat(10000), 4, 13)).toBe(13); + expect(wrappedIndentColumns("\t".repeat(10000), 4, 13)).toBe(12); + expect(wrappedIndentColumns("\tcode", 4, 3)).toBe(0); + expect(wrappedIndentColumns(" code", 4, 0)).toBe(0); + }); +}); From 4de29a7392367738cc981db4cac9b22ba0740c17 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:33:21 +0530 Subject: [PATCH 2/2] fix --- src/cm/indentedLineWrapping.ts | 7 ++++++- tests/unit/indentedLineWrapping.test.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cm/indentedLineWrapping.ts b/src/cm/indentedLineWrapping.ts index 4b86a5ff13..305506ad4f 100644 --- a/src/cm/indentedLineWrapping.ts +++ b/src/cm/indentedLineWrapping.ts @@ -22,6 +22,10 @@ export function wrappedIndentColumns( ): number { // Unindented/minified lines need no tab scan, even when they are megabytes long. if (limit <= 0 || (text[0] !== " " && text[0] !== "\t")) return 0; + // Include content tabs deliberately: negative text-indent changes the origin + // of every tab stop on the first visual row. For " key\tvalue", using 2ch + // shifts "value" left by two columns in Chromium; 4ch preserves its position. + // The extra continuation indent is the tradeoff for keeping tab alignment. const hasTabs = text.includes("\t"); const step = hasTabs ? tabSize : 1; const cap = Math.max(0, Math.floor(limit / step) * step); @@ -160,7 +164,8 @@ const plugin = ViewPlugin.fromClass( * Browser-native soft wrapping, with no widgets, replacement text, or input * handlers. Line attributes leave CodeMirror's text/selection/composition DOM * under its own control. `ch` tracks font changes without rounding tab stops. - * Mixed tab/space indents round up to a tab stop; oversized indents are capped + * Lines containing tabs round their indent up to a tab stop, including when + * tabs occur after the leading whitespace. Oversized indents are capped * at half the available columns so narrow panes still have room for content. */ export function indentedLineWrapping(): Extension { diff --git a/tests/unit/indentedLineWrapping.test.ts b/tests/unit/indentedLineWrapping.test.ts index 62b2c98405..8557f57e0d 100644 --- a/tests/unit/indentedLineWrapping.test.ts +++ b/tests/unit/indentedLineWrapping.test.ts @@ -12,7 +12,7 @@ describe("wrapped line indentation", () => { expect(wrappedIndentColumns(" \t code", 4, 40)).toBe(8); expect(wrappedIndentColumns("\t code", 8, 40)).toBe(16); }); - it("preserves tab stops even when a tab appears after the indentation", () => { + it("rounds space indentation to preserve content tab alignment", () => { expect(wrappedIndentColumns(" key\tvalue", 4, 40)).toBe(4); }); it("caps deep indentation without introducing fractional tab stops", () => {