diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 333a054b..81fd80ee 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -34,6 +34,7 @@ import { selectLineStart, } from "@codemirror/commands"; import { markdownImages } from "./markdownImages"; +import { selectionMarks } from "./selectionMarks"; import type { EditorExtras } from "@/platform/editorSettings"; import { openideaHighlightStyle } from "./highlight"; import { @@ -504,6 +505,7 @@ export function caretExtension(prefs: EditorExtras): Extension { // Before drawSelection: its measure request must run before the cursor layer redraws. a.smoothCaretMovement ? caretGlide : [], drawSelection({ cursorBlinkRate: a.caretBlinking ? Math.max(100, Math.min(5000, a.caretBlinkPeriod)) * 2 : 0 }), + selectionMarks, classes.length ? EditorView.editorAttributes.of({ class: classes.join(" ") }) : [], ]; } diff --git a/src/editor/core/cm/selectionMarks.test.ts b/src/editor/core/cm/selectionMarks.test.ts new file mode 100644 index 00000000..7c3c2e77 --- /dev/null +++ b/src/editor/core/cm/selectionMarks.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorSelection, EditorState, Prec, type Extension } from "@codemirror/state"; +import { Decoration, EditorView } from "@codemirror/view"; +import { selectionMarks } from "./selectionMarks"; + +const views: EditorView[] = []; +afterEach(() => views.splice(0).forEach((v) => v.destroy())); + +function view(doc: string, selection: EditorSelection, extensions: Extension) { + extensions = [EditorState.allowMultipleSelections.of(true), extensions]; + const parent = document.createElement("div"); + document.body.appendChild(parent); + const v = new EditorView({ state: EditorState.create({ doc, selection, extensions }), parent }); + views.push(v); + return v; +} + +/** A background mark as features add it (lint range, find match, usage, semantic bg…). */ +const backgroundMark = (cls: string, from: number, to: number, prec: (e: Extension) => Extension = (e) => e) => + prec(EditorView.decorations.of(Decoration.set(Decoration.mark({ class: cls }).range(from, to)))); + +const DOC = "return request.app.state.credits"; + +describe("selectionMarks", () => { + it.each([ + ["default", (e: Extension) => e], + ["high (Lezer highlighter)", Prec.high], + ["lowest", Prec.lowest], + ])("paints selected text inside a %s-precedence background mark", (_, prec) => { + // The reporter's case: a light-scheme warning background over `request.app.state.credits`. + const v = view(DOC, EditorSelection.single(7, DOC.length), [ + backgroundMark("cm-lintRange-warning", 7, DOC.length, prec), + selectionMarks, + ]); + const selected = [...v.contentDOM.querySelectorAll(".cm-openidea-selected")]; + expect(selected.map((e) => e.textContent).join("")).toBe(DOC.slice(7)); + for (const el of selected) expect(el.closest(".cm-lintRange-warning")).not.toBeNull(); + }); + + it("covers every range of a multi-selection, partially overlapping marks too", () => { + const v = view(DOC, EditorSelection.create([EditorSelection.range(0, 3), EditorSelection.range(10, 20)], 1), [ + backgroundMark("cm-openidea-findMatch", 15, 24), + selectionMarks, + ]); + const text = [...v.contentDOM.querySelectorAll(".cm-openidea-selected")].map((e) => e.textContent).join("|"); + expect(text.replace(/\|/g, "")).toBe(DOC.slice(0, 3) + DOC.slice(10, 20)); + const inMatch = [...v.contentDOM.querySelectorAll(".cm-openidea-findMatch .cm-openidea-selected")]; + expect(inMatch.map((e) => e.textContent).join("")).toBe(DOC.slice(15, 20)); + }); + + it("adds nothing for an empty selection and clears when the selection collapses", () => { + const v = view(DOC, EditorSelection.single(0, 6), [selectionMarks]); + expect(v.contentDOM.querySelector(".cm-openidea-selected")).not.toBeNull(); + v.dispatch({ selection: EditorSelection.cursor(3) }); + expect(v.contentDOM.querySelector(".cm-openidea-selected")).toBeNull(); + }); +}); diff --git a/src/editor/core/cm/selectionMarks.ts b/src/editor/core/cm/selectionMarks.ts new file mode 100644 index 00000000..eadee2ce --- /dev/null +++ b/src/editor/core/cm/selectionMarks.ts @@ -0,0 +1,47 @@ +import { Prec, RangeSetBuilder, type Extension } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view"; + +/** + * `drawSelection` paints the selection in a layer behind the text, so any mark with its own + * background (light-scheme warnings, find matches, usages, semantic backgrounds…) hides it. + * This repaints selected text in the innermost span: higher-precedence marks nest closer to the + * text, and the only other `Prec.highest` marks (LSP semantic tokens) set colour, not background. + */ +const selectedMark = Decoration.mark({ class: "cm-openidea-selected" }); + +function selectedRanges(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const ranges = view.state.selection.ranges.filter((r) => !r.empty); + if (!ranges.length) return Decoration.none; + for (const { from: vFrom, to: vTo } of view.visibleRanges) + for (const r of ranges) { + const from = Math.max(r.from, vFrom); + const to = Math.min(r.to, vTo); + if (from < to) builder.add(from, to, selectedMark); + } + return builder.finish(); +} + +const plugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = selectedRanges(view); + } + update(u: ViewUpdate) { + if (u.selectionSet || u.docChanged || u.viewportChanged) this.decorations = selectedRanges(u.view); + } + }, + { decorations: (p) => p.decorations }, +); + +const theme = EditorView.baseTheme({ + ".cm-openidea-selected": { + backgroundColor: "var(--editor-selection-inactive, var(--bg-editor-selection-inactive))", + }, + "&.cm-focused .cm-openidea-selected": { + backgroundColor: "var(--editor-selection, var(--bg-editor-selection))", + }, +}); + +export const selectionMarks: Extension = [Prec.highest(plugin), theme]; diff --git a/src/features/notebook/CellEditor.tsx b/src/features/notebook/CellEditor.tsx index 627ea58f..9089b89f 100644 --- a/src/features/notebook/CellEditor.tsx +++ b/src/features/notebook/CellEditor.tsx @@ -12,6 +12,7 @@ import { defaultKeymap, indentWithTab } from "@codemirror/commands"; import { bracketMatching, indentOnInput, syntaxHighlighting } from "@codemirror/language"; import { openideaTheme } from "@/editor/core/cm/theme"; import { openideaHighlightStyle } from "@/editor/core/cm/highlight"; +import { selectionMarks } from "@/editor/core/cm/selectionMarks"; import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages"; import { tx } from "@/platform/i18n"; import type { NotebookSession } from "./session"; @@ -43,6 +44,7 @@ export function CellEditor({ syntaxHighlighting(openideaHighlightStyle), lineNumbers(), drawSelection(), + selectionMarks, highlightActiveLine(), highlightSpecialChars(), bracketMatching(), diff --git a/src/features/vcs/cm.ts b/src/features/vcs/cm.ts index 131c06af..07ae5810 100644 --- a/src/features/vcs/cm.ts +++ b/src/features/vcs/cm.ts @@ -270,9 +270,10 @@ export const viewerTheme = EditorView.theme( "&.cm-openidea-ligatures .cm-scroller": { fontVariantLigatures: "normal" }, ".cm-content": { caretColor: "var(--caret, var(--fg-strong))", WebkitUserSelect: "text", userSelect: "text" }, ...caretStyles, - "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection": { - backgroundColor: "var(--bg-selected-strong)", - }, + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection, .cm-openidea-selected, &.cm-focused .cm-openidea-selected": + { + backgroundColor: "var(--bg-selected-strong)", + }, ".cm-activeLine": { backgroundColor: "var(--bg-current-line)" }, ".cm-gutters": { backgroundColor: "var(--bg-app)",