diff --git a/e2e/themes.spec.ts b/e2e/themes.spec.ts index ce717dac..30cc7799 100644 --- a/e2e/themes.spec.ts +++ b/e2e/themes.spec.ts @@ -111,7 +111,7 @@ for (const palette of palettes) { ); await editor.locator(".cm-content").click(); await page.keyboard.press("Meta+a"); - await expect(editor.locator(".cm-selectionBackground").first()).toBeVisible(); + await expect(editor.locator(".cm-selectionBackground:visible").first()).toBeVisible(); await page.keyboard.press("ArrowRight"); await page.keyboard.press("Meta+f"); await page.getByRole("textbox", { name: "Find", exact: true }).fill("Order"); diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 81fd80ee..43501431 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -35,6 +35,7 @@ import { } from "@codemirror/commands"; import { markdownImages } from "./markdownImages"; import { selectionMarks } from "./selectionMarks"; +import { seamlessSelection } from "./selectionLayer"; import type { EditorExtras } from "@/platform/editorSettings"; import { openideaHighlightStyle } from "./highlight"; import { @@ -506,6 +507,7 @@ export function caretExtension(prefs: EditorExtras): Extension { a.smoothCaretMovement ? caretGlide : [], drawSelection({ cursorBlinkRate: a.caretBlinking ? Math.max(100, Math.min(5000, a.caretBlinkPeriod)) * 2 : 0 }), selectionMarks, + seamlessSelection(), classes.length ? EditorView.editorAttributes.of({ class: classes.join(" ") }) : [], ]; } diff --git a/src/editor/core/cm/selectionLayer.test.ts b/src/editor/core/cm/selectionLayer.test.ts new file mode 100644 index 00000000..3d489439 --- /dev/null +++ b/src/editor/core/cm/selectionLayer.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { RectangleMarker } from "@codemirror/view"; +import { snapMarkers } from "./selectionLayer"; + +describe("snapMarkers", () => { + it("makes stacked selection rectangles share their edge exactly", () => { + // Middle piece's bottom (top + height) and the last line's top differ by float error around x.25. + const middle = new RectangleMarker("cm-selectionBackground", 6, 26.249999999, 500, 22.000000002); + const last = new RectangleMarker("cm-selectionBackground", 6, 48.25, 40, 22); + const [a, b] = snapMarkers([middle, last], "cm-selectionBackground", 2) as [RectangleMarker, RectangleMarker]; + expect(a.top + a.height).toBe(b.top); + }); + + it("snaps edges to device pixels and keeps horizontal geometry", () => { + const m = snapMarkers([new RectangleMarker("x", 6.3, 10.3, 100, 21.9)], "x", 2)[0]!; + expect(m.top).toBe(10.5); + expect(m.top + m.height).toBe(32); + expect(m.left).toBe(6.3); + expect(m.width).toBe(100); + }); +}); diff --git a/src/editor/core/cm/selectionLayer.ts b/src/editor/core/cm/selectionLayer.ts new file mode 100644 index 00000000..034c4589 --- /dev/null +++ b/src/editor/core/cm/selectionLayer.ts @@ -0,0 +1,48 @@ +import { EditorView, RectangleMarker, layer } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; + +/** Rounds a CSS length to the nearest device pixel. */ +export function snapToDevicePixel(v: number, dpr: number): number { + return Math.round(v * dpr) / dpr; +} + +/** + * Re-emits selection rectangles with top and bottom edges snapped to device pixels. A multi-line + * selection is drawn as several stacked rectangles; their shared edge comes from separate float + * sums, and when it lands on a fractional pixel each side rounds differently and a 1px seam shows. + */ +export function snapMarkers(markers: readonly RectangleMarker[], className: string, dpr: number): RectangleMarker[] { + return markers.map((m) => { + const top = snapToDevicePixel(m.top, dpr); + const bottom = snapToDevicePixel(m.top + m.height, dpr); + return new RectangleMarker(className, m.left, top, m.width, bottom - top); + }); +} + +const CLASS = "cm-selectionBackground"; + +const snappedSelectionLayer = layer({ + above: false, + class: "cm-selectionLayer", + mount: (dom) => dom.classList.add("cm-openidea-selectionLayer"), + markers(view) { + const dpr = window.devicePixelRatio || 1; + const out: RectangleMarker[] = []; + for (const r of view.state.selection.ranges) + if (!r.empty) out.push(...snapMarkers(RectangleMarker.forRange(view, CLASS, r), CLASS, dpr)); + return out; + }, + update: (u) => u.docChanged || u.selectionSet || u.viewportChanged, +}); + +/** Use with `drawSelection()`: replaces its selection layer, which is hidden. */ +export function seamlessSelection(): Extension { + return [ + snappedSelectionLayer, + EditorView.theme({ + ".cm-selectionLayer:not(.cm-openidea-selectionLayer)": { + display: "none", + }, + }), + ]; +} diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index ab6727b9..23eb911c 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -177,7 +177,17 @@ export const openideaTheme = EditorView.theme({ "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { background: "var(--editor-selection)", }, - ".cm-activeLine": { backgroundColor: "var(--editor-caret-row)" }, + // Row backgrounds go on a pseudo-element under the selection layer: on the line itself they would hide it. + ".cm-activeLine": { backgroundColor: "transparent", "--openidea-line-bg": "var(--editor-caret-row)" }, + ".cm-activeLine, .cm-openidea-lineBg": { position: "relative" }, + ".cm-activeLine::before, .cm-openidea-lineBg::before": { + content: '""', + position: "absolute", + inset: "0", + zIndex: "-1000", + background: "var(--openidea-line-bg)", + pointerEvents: "none", + }, ".cm-selectionMatch": { backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent))", }, @@ -263,7 +273,13 @@ export const openideaTheme = EditorView.theme({ ".cm-foldPlaceholder:hover": { backgroundColor: "color-mix(in srgb, var(--fg-muted) 24%, transparent)" }, // Indent guides (cm/indentGuides.ts) - ".cm-openidea-guides": { + // 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": { + 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%", diff --git a/src/features/debugger/editor/breakpointGutter.ts b/src/features/debugger/editor/breakpointGutter.ts index 704f442f..a81be943 100644 --- a/src/features/debugger/editor/breakpointGutter.ts +++ b/src/features/debugger/editor/breakpointGutter.ts @@ -84,7 +84,7 @@ class BreakpointMarker extends GutterMarker { } } -const bpLine = Decoration.line({ class: styles.bpLine }); +const bpLine = Decoration.line({ class: `${styles.bpLine} cm-openidea-lineBg` }); const DISABLED_LOOKS = new Set(["disabled", "muted", "methodDisabled", "fieldDisabled"]); const setMarkers = StateEffect.define>(); diff --git a/src/features/debugger/editor/editor.module.css b/src/features/debugger/editor/editor.module.css index 24653ac7..1ab48dc6 100644 --- a/src/features/debugger/editor/editor.module.css +++ b/src/features/debugger/editor/editor.module.css @@ -126,11 +126,11 @@ /* Beat the caret row (`.cm-activeLine`), as IDEA does; the suspended line wins over a breakpoint. */ :global(.cm-editor) :global(.cm-line).bpLine { - background: var(--bg-breakpoint-line); + --openidea-line-bg: var(--bg-breakpoint-line); } :global(.cm-editor) :global(.cm-line).execLine { - background: var(--bg-debug-line); + --openidea-line-bg: var(--bg-debug-line); } .inlineValue { diff --git a/src/features/debugger/editor/execution.ts b/src/features/debugger/editor/execution.ts index 749bce22..3751f4c4 100644 --- a/src/features/debugger/editor/execution.ts +++ b/src/features/debugger/editor/execution.ts @@ -61,7 +61,7 @@ export function inlineValuesFor(text: string, values: Record): s function lineDecorations(state: EditorState, exec: Exec | null): DecorationSet { if (!exec || exec.line >= state.doc.lines) return Decoration.none; const line = state.doc.line(exec.line + 1); - return Decoration.set([Decoration.line({ class: styles.execLine }).range(line.from)]); + return Decoration.set([Decoration.line({ class: `${styles.execLine} cm-openidea-lineBg` }).range(line.from)]); } export function executionPoint(ctx: EditorDocContext): Extension { diff --git a/src/features/notebook/CellEditor.tsx b/src/features/notebook/CellEditor.tsx index 9089b89f..bcf558b8 100644 --- a/src/features/notebook/CellEditor.tsx +++ b/src/features/notebook/CellEditor.tsx @@ -13,6 +13,7 @@ import { bracketMatching, indentOnInput, syntaxHighlighting } from "@codemirror/ import { openideaTheme } from "@/editor/core/cm/theme"; import { openideaHighlightStyle } from "@/editor/core/cm/highlight"; import { selectionMarks } from "@/editor/core/cm/selectionMarks"; +import { seamlessSelection } from "@/editor/core/cm/selectionLayer"; import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages"; import { tx } from "@/platform/i18n"; import type { NotebookSession } from "./session"; @@ -45,6 +46,7 @@ export function CellEditor({ lineNumbers(), drawSelection(), selectionMarks, + seamlessSelection(), highlightActiveLine(), highlightSpecialChars(), bracketMatching(),