diff --git a/e2e/cornerInset.spec.ts b/e2e/cornerInset.spec.ts new file mode 100644 index 000000000..ef193a09a --- /dev/null +++ b/e2e/cornerInset.spec.ts @@ -0,0 +1,32 @@ +import { expect, openFromTree, test } from "./fixtures"; + +/** The end of a long line must stay visible: the floating corner widgets leave it room. */ +test("the inspection widget does not cover the end of a long line", async ({ workbench: page }) => { + const editor = await openFromTree(page, "build.gradle.kts"); + const content = editor.locator(".cm-content"); + await content.click(); + await page.keyboard.press("Meta+a"); + await page.keyboard.insertText("x".repeat(600)); + await expect(editor.locator(".cm-line")).toHaveCount(1); + await expect(editor.locator(".cm-openidea-corner")).toBeAttached(); + + const scroller = editor.locator(".cm-scroller"); + await expect.poll(() => scroller.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0); + await scroller.evaluate((el) => { + el.scrollLeft = el.scrollWidth; + }); + + const rects = await editor.evaluate((el) => { + const line = el.querySelector(".cm-line")!; + const range = document.createRange(); + range.selectNodeContents(line); + return { + textRight: range.getBoundingClientRect().right, + cornerLeft: el.querySelector(".cm-openidea-corner")!.getBoundingClientRect().left, + scrollLeft: el.querySelector(".cm-scroller")!.scrollLeft, + }; + }); + // Scrolled as far right as it goes, and the last character sits left of the widget. + expect(rects.scrollLeft).toBeGreaterThan(0); + expect(rects.cornerLeft).toBeGreaterThan(rects.textRight); +}); diff --git a/src/editor/api.ts b/src/editor/api.ts index 25f99448d..3e2ae89c3 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -33,6 +33,7 @@ import type { LanguageDescription } from "@codemirror/language"; import type { LineEnding } from "@/platform/ipc"; import { create } from "zustand"; import { maybeEditorService } from "@/workbench/contributions"; +import { cornerInset } from "@/editor/core/cm/cornerInset"; /** Documents above this size (UTF-16 units) skip per-line decorations. */ export const HEAVY_LIMIT = 8_000_000; @@ -358,6 +359,8 @@ export function editorCorner(view: EditorView): HTMLElement { gap: "4px", }); view.dom.appendChild(el); + // The lines leave room for its measured width, so it never covers a line end. + view.plugin(cornerInset)?.track(el); return el; } diff --git a/src/editor/core/cm/cornerInset.test.ts b/src/editor/core/cm/cornerInset.test.ts new file mode 100644 index 000000000..d62bb9a22 --- /dev/null +++ b/src/editor/core/cm/cornerInset.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { editorCorner } from "@/editor/api"; +import { cornerInset } from "./cornerInset"; + +let view: EditorView; +let resize: (() => void) | null = null; + +beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + resize = callback; + } + observe() {} + disconnect() { + resize = null; + } + }, + ); + view = new EditorView({ + state: EditorState.create({ doc: "const a = 1;", extensions: [cornerInset] }), + parent: document.body, + }); +}); + +afterEach(() => { + view.destroy(); + vi.unstubAllGlobals(); +}); + +const inset = () => view.dom.style.getPropertyValue("--cm-corner-inset"); + +/** The corner's measured width arrives from the ResizeObserver, not from jsdom. */ +function resizeTo(width: number) { + const corner = view.dom.querySelector(".cm-openidea-corner"); + if (!corner) throw new Error("no corner"); + corner.getBoundingClientRect = () => ({ width }) as DOMRect; + resize?.(); +} + +describe("corner inset", () => { + it("leaves the corner's width plus a gap for the text: 20px check, 32+ with counts", () => { + editorCorner(view); + resizeTo(20); + expect(inset()).toBe("24px"); + resizeTo(32); + expect(inset()).toBe("36px"); + }); + + it("clears the reserved room when the corner is empty", () => { + editorCorner(view); + resizeTo(20); + resizeTo(0); + expect(inset()).toBe(""); + }); + + it("keeps at least half of the editor width for the text", () => { + Object.defineProperty(view.scrollDOM, "clientWidth", { configurable: true, value: 200 }); + editorCorner(view); + resizeTo(400); + expect(inset()).toBe("100px"); + }); + + it("does nothing without the extension", () => { + const plain = new EditorView({ state: EditorState.create({ doc: "x" }), parent: document.body }); + expect(() => editorCorner(plain)).not.toThrow(); + expect(plain.dom.style.getPropertyValue("--cm-corner-inset")).toBe(""); + plain.destroy(); + }); +}); diff --git a/src/editor/core/cm/cornerInset.ts b/src/editor/core/cm/cornerInset.ts new file mode 100644 index 000000000..de0ae8fe0 --- /dev/null +++ b/src/editor/core/cm/cornerInset.ts @@ -0,0 +1,81 @@ +/** + * Keeps the end of a line clear of the floating corner widgets (`editorCorner()` + * in `editor/api.ts`: the inspection widget, the Review Mode group). + * + * The corner is absolutely positioned in `.cm-editor`, outside `.cm-scroller`: + * scrolling does not move it and it does not count in `scrollWidth`, so at full + * horizontal scroll the last characters of a line land under it. Its width is + * not a constant (a green check, then counts and previous/next buttons as + * problems appear), so the lines' right padding follows its measured width + * through `--cm-corner-inset` (`theme.ts`), the way the fixed line padding + * already reserves room for the overview lane. + */ +import { type EditorView, ViewPlugin } from "@codemirror/view"; + +/** Space between the corner's left edge and the end of a line. */ +const CORNER_GAP = 4; + +/** Width changes of the corner that matter: `getBoundingClientRect()` is fractional. */ +const round = (width: number) => Math.ceil(width); + +class CornerInset { + private corner: HTMLElement | null = null; + private observer: ResizeObserver | null = null; + private inset = -1; + + constructor(private readonly view: EditorView) { + this.sync(); + } + + /** A new corner: `editorCorner()` calls this right after appending it. */ + track(corner: HTMLElement) { + if (corner === this.corner) return; + this.observer?.disconnect(); + this.corner = corner; + if (typeof ResizeObserver === "undefined") return; + // The widget's content grows and shrinks (diagnostic counts, Review Mode). + this.observer = new ResizeObserver(() => this.measure()); + this.observer.observe(corner); + this.measure(); + } + + update() { + // The corner is created lazily, possibly after this plugin: catch it on the next update. + if (!this.corner) this.sync(); + } + + private sync() { + const corner = this.view.dom.querySelector(":scope > .cm-openidea-corner"); + if (corner) this.track(corner); + } + + private measure() { + const corner = this.corner; + if (!corner) return; + const width = corner.getBoundingClientRect().width; + let inset = width > 0 ? round(width) + CORNER_GAP : 0; + // A narrow window or a wide corner: keep at least half of the width for text. + const room = this.view.scrollDOM.clientWidth; + if (inset > 0 && room > 0) inset = Math.min(inset, Math.floor(room / 2)); + this.apply(inset); + } + + private apply(inset: number) { + if (inset === this.inset) return; + this.inset = inset; + const style = this.view.dom.style; + // An empty corner needs no room; the `theme.ts` fallback is 0. + if (inset > 0) style.setProperty("--cm-corner-inset", `${inset}px`); + else style.removeProperty("--cm-corner-inset"); + // The line boxes changed: CodeMirror re-measures the content and the scroll extent. + this.view.requestMeasure(); + } + + destroy() { + this.observer?.disconnect(); + this.observer = null; + this.corner = null; + } +} + +export const cornerInset = ViewPlugin.fromClass(CornerInset); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 4f52a4d7f..e029468c0 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -34,6 +34,7 @@ import { openideaHighlightStyle } from "./highlight"; import { openideaTheme } from "./theme"; import { caretRowHighlight } from "./caretRow"; import { changeLane, overviewLane } from "./lanes"; +import { cornerInset } from "./cornerInset"; import { foldingExtension } from "./folding"; import { syntaxHighlightField } from "./syntaxHighlight"; import { indentGuides } from "./indentGuides"; @@ -370,6 +371,7 @@ export function baseExtensions(o: SetupOptions): Extension { methodLinesField, findExtension(), overviewLane, + cornerInset, codeLensExtension, EditorView.contentAttributes.of({ spellcheck: "false", diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index b60f2e2ea..553e57924 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -153,7 +153,8 @@ export const openideaTheme = EditorView.theme({ caretColor: "var(--caret, var(--fg-strong))", cursor: "text", }, - ".cm-line": { padding: "0 16px 0 6px" }, + // The right side also leaves room for the floating corner widgets (core/cm/cornerInset.ts). + ".cm-line": { padding: "0 calc(16px + var(--cm-corner-inset, 0px)) 0 6px" }, ...caretStyles, "&.cm-openidea-ligatures .cm-scroller, &.cm-openidea-ligatures .cm-openidea-sticky": { fontVariantLigatures: "normal", @@ -370,5 +371,7 @@ export const openideaTheme = EditorView.theme({ ".cm-openidea-stickyText": { flex: "1", overflow: "hidden" }, // Soft-wrap: continuation rows indent slightly, like IDEA's wrap sign column. - "&.cm-lineWrapping .cm-line, .cm-lineWrapping .cm-line": { paddingRight: "24px" }, + "&.cm-lineWrapping .cm-line, .cm-lineWrapping .cm-line": { + paddingRight: "calc(24px + var(--cm-corner-inset, 0px))", + }, });