diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 2c8c570c..2c404308 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -1,14 +1,7 @@ /** Editor actions (ids from keymaps/intellij-macos.ts). */ import { EditorSelection, type EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { - addCursorAbove, - addCursorBelow, - indentSelection, - moveLineDown, - moveLineUp, - toggleBlockComment, -} from "@codemirror/commands"; +import { indentSelection, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands"; import { foldEffect, syntaxTree, toggleFold } from "@codemirror/language"; import { acceptCompletion, completionStatus } from "@codemirror/autocomplete"; import { executeAction, registerActions, type Action } from "@/platform/actions"; @@ -66,6 +59,7 @@ import { } from "./cm/caretCommands"; import { isOverwrite, toggleOverwrite } from "./cm/overwrite"; import { isColumnMode, toggleColumnMode } from "./cm/columnMode"; +import { cloneCaretAbove, cloneCaretBelow } from "./cm/verticalMotion"; import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras"; import { cyclicExpandWord } from "./wordCompletion"; import { qualifiedReference } from "./copyReference"; @@ -609,8 +603,8 @@ export function registerEditorActions(): () => void { text("code.autoIndentLines", "Auto-Indent Lines", (v) => !v.state.readOnly && indentSelection(v), { keywords: ["reindent", "fix indentation"], }), - text("editor.cloneCaretAbove", "Clone Caret Above", (v) => addCursorAbove(v), { category: "Editor Actions" }), - text("editor.cloneCaretBelow", "Clone Caret Below", (v) => addCursorBelow(v), { category: "Editor Actions" }), + text("editor.cloneCaretAbove", "Clone Caret Above", (v) => cloneCaretAbove(v), { category: "Editor Actions" }), + text("editor.cloneCaretBelow", "Clone Caret Below", (v) => cloneCaretBelow(v), { category: "Editor Actions" }), text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), { category: "Editor Actions", // ⌃M is Show Commit Message History in the commit window. diff --git a/src/editor/core/cm/baseKeymap.ts b/src/editor/core/cm/baseKeymap.ts index 179eaaec..8e4ef790 100644 --- a/src/editor/core/cm/baseKeymap.ts +++ b/src/editor/core/cm/baseKeymap.ts @@ -3,11 +3,14 @@ * Those keys belong to IDE actions instead (keymaps in `platform/keymaps/`): * ⌘/Ctrl+Enter is Split Line, Ctrl+M / ⌃L Scroll to Center, Ctrl+[ / ] code * block navigation, ⌥⌘↑↓ / Ctrl+Alt+↑↓ Previous / Next Occurrence. + * Line up / down (with ⇧ too) keep the caret's visible column (`verticalMotion`). */ -import type { KeyBinding } from "@codemirror/view"; +import type { Command, KeyBinding } from "@codemirror/view"; import { addCursorAbove, addCursorBelow, + cursorLineDown as cmCursorLineDown, + cursorLineUp as cmCursorLineUp, defaultKeymap, historyKeymap, indentLess, @@ -15,8 +18,11 @@ import { insertBlankLine, redo, selectLine, + selectLineDown as cmSelectLineDown, + selectLineUp as cmSelectLineUp, toggleTabFocusMode, } from "@codemirror/commands"; +import { cursorLineDown, cursorLineUp, selectLineDown, selectLineUp } from "./verticalMotion"; const DROPPED = new Set([ insertBlankLine, // Mod-Enter @@ -28,8 +34,20 @@ const DROPPED = new Set([ indentMore, // Mod-] ]); +const VERTICAL = new Map([ + [cmCursorLineUp, cursorLineUp], + [cmCursorLineDown, cursorLineDown], + [cmSelectLineUp, selectLineUp], + [cmSelectLineDown, selectLineDown], +]); + +const vertical = (b: KeyBinding): KeyBinding => + VERTICAL.has(b.run) || VERTICAL.has(b.shift) + ? { ...b, run: VERTICAL.get(b.run) ?? b.run, shift: VERTICAL.get(b.shift) ?? b.shift } + : b; + export const editorBaseKeymap: readonly KeyBinding[] = [ - ...defaultKeymap.filter((b) => !DROPPED.has(b.run)), + ...defaultKeymap.filter((b) => !DROPPED.has(b.run)).map(vertical), ...historyKeymap, // IDEA's Windows Redo is Ctrl+Shift+Z (Ctrl+Y is Delete Line); CodeMirror only binds it on macOS and Linux. { win: "Ctrl-Shift-z", run: redo, preventDefault: true }, diff --git a/src/editor/core/cm/columnMode.test.ts b/src/editor/core/cm/columnMode.test.ts index 3fc4b6cc..e3ec57c7 100644 --- a/src/editor/core/cm/columnMode.test.ts +++ b/src/editor/core/cm/columnMode.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { columnModeExtension, extendBlock, isColumnMode, toggleColumnMode } from "./columnMode"; +import { columnModeExtension, extendBlock, isColumnMode, rectangleRanges, toggleColumnMode } from "./columnMode"; let view: EditorView | null = null; afterEach(() => { @@ -60,3 +60,49 @@ describe("Column Selection Mode", () => { ]); }); }); + +describe("block selection in visible columns", () => { + // `张三` takes four columns, so `//` starts at column 15 on both lines. + const doc = 'name = "张三" // 中文注释\nid = 42 // answer'; + const [first, second] = doc.split("\n") as [string, string]; + const from2 = first.length + 1; + + it("⇧↓ / ⇧→ keep the block on the same columns across CJK text", () => { + const v = make(doc, first.indexOf("//")); + toggleColumnMode(v); + extendBlock(v, 1, 0); + extendBlock(v, 0, 1); + extendBlock(v, 0, 1); + expect(ranges(v)).toEqual([ + [first.indexOf("//"), first.indexOf("//") + 2], + [from2 + second.indexOf("//"), from2 + second.indexOf("//") + 2], + ]); + }); + + it("⇧→ steps over a wide character as one unit", () => { + const v = make("张三四\nabcdef", 0); + toggleColumnMode(v); + extendBlock(v, 0, 1); + extendBlock(v, 1, 0); + // One ideograph on line 1 is two letters on line 2. + expect(ranges(v)).toEqual([ + [0, 1], + [4, 6], + ]); + }); + + it("mouse rectangles map visible columns back to UTF-16 offsets per line", () => { + const v = make(`${doc}\n😀😀ab`, 0); + const line3 = v.state.doc.line(3); + const got = rectangleRanges(v.state, { line: 1, col: 15 }, { line: 3, col: 17 }).map((r) => [r.from, r.to]); + expect(got).toEqual([ + [first.indexOf("//"), first.indexOf("//") + 2], + [from2 + second.indexOf("//"), from2 + second.indexOf("//") + 2], + // Line 3 is six columns wide, left of the block: a caret at its end. + [line3.to, line3.to], + ]); + // Columns 2..6 of "😀😀ab": the second emoji (code units 2..4) and "ab". + const inner = rectangleRanges(v.state, { line: 3, col: 2 }, { line: 3, col: 6 }); + expect(inner.map((r) => [r.from, r.to])).toEqual([[line3.from + 2, line3.from + 6]]); + }); +}); diff --git a/src/editor/core/cm/columnMode.ts b/src/editor/core/cm/columnMode.ts index 13b5571c..7a1d1847 100644 --- a/src/editor/core/cm/columnMode.ts +++ b/src/editor/core/cm/columnMode.ts @@ -7,14 +7,14 @@ import { Prec, StateEffect, StateField, - countColumn, - findColumn, type EditorState, type Extension, + type SelectionRange, } from "@codemirror/state"; -import { EditorView, keymap, rectangularSelection } from "@codemirror/view"; +import { EditorView, keymap, type MouseSelectionStyle } from "@codemirror/view"; +import { offsetAtVisibleColumn, stepVisibleColumn, visibleColumn, visibleWidth } from "./visibleColumn"; -/** A block between two visual columns (tabs expanded) of two lines. */ +/** A block between two visible columns (tabs expanded, wide characters 2) of two lines. */ interface Block { anchorLine: number; anchorCol: number; @@ -53,11 +53,11 @@ export function toggleColumnMode(view: EditorView): boolean { return true; } -/** One range per line from the anchor column to the head column (visual columns, clamped to each line). */ +/** One range per line from the anchor column to the head column (visible columns, clamped to each line). */ export function blockSelection(state: EditorState, b: Block): EditorSelection { const ranges = []; const step = b.headLine >= b.anchorLine ? 1 : -1; - const at = (text: string, col: number) => findColumn(text, col, state.tabSize); + const at = (text: string, col: number) => offsetAtVisibleColumn(text, col, state.tabSize); for (let n = b.anchorLine; ; n += step) { const line = state.doc.line(n); ranges.push(EditorSelection.range(line.from + at(line.text, b.anchorCol), line.from + at(line.text, b.headCol))); @@ -66,17 +66,6 @@ export function blockSelection(state: EditorState, b: Block): EditorSelection { return EditorSelection.create(ranges, ranges.length - 1); } -/** The visual column one character left / right of `col` (a tab is one step; past the line end, one column). */ -export function stepColumn(text: string, col: number, d: number, tabSize: number): number { - if (!d) return col; - const width = countColumn(text, tabSize); - if (d > 0 ? col >= width : col > width) return Math.max(0, col + d); - const pos = findColumn(text, col, tabSize); - const at = countColumn(text, tabSize, pos); - if (d > 0) return at > col ? at : countColumn(text, tabSize, pos + 1); - return pos === 0 ? 0 : countColumn(text, tabSize, pos - 1); -} - /** ⇧+arrow in column mode: moves the block's head by a line / column. */ export function extendBlock(view: EditorView, dLine: number, dCol: number): boolean { const state = view.state; @@ -87,7 +76,7 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool const main = state.selection.main; const a = state.doc.lineAt(main.anchor); const h = state.doc.lineAt(main.head); - const col = (text: string, offset: number) => countColumn(text, state.tabSize, offset); + const col = (text: string, offset: number) => visibleColumn(text, offset, state.tabSize); b = { anchorLine: a.number, anchorCol: col(a.text, main.anchor - a.from), @@ -96,7 +85,11 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool }; } const headLine = Math.max(1, Math.min(state.doc.lines, b.headLine + dLine)); - const next = { ...b, headLine, headCol: stepColumn(state.doc.line(b.headLine).text, b.headCol, dCol, state.tabSize) }; + const next = { + ...b, + headLine, + headCol: stepVisibleColumn(state.doc.line(b.headLine).text, b.headCol, dCol, state.tabSize), + }; view.dispatch({ selection: blockSelection(state, next), effects: setBlock.of(next), @@ -106,18 +99,73 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool return true; } +interface BlockPoint { + line: number; + col: number; +} + +/** The line and visible column under the pointer; past the line end, columns of the default character width. */ +function blockPointAt(view: EditorView, e: MouseEvent): BlockPoint { + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); + const line = view.state.doc.lineAt(pos); + let col = visibleColumn(line.text, pos - line.from, view.state.tabSize); + if (pos === line.to) { + const end = view.coordsAtPos(line.to, -1); + if (end) col += Math.max(0, Math.round((e.clientX - end.left) / view.defaultCharacterWidth)); + } + return { line: line.number, col }; +} + +/** One range per line of the rectangle between two pointer points; lines ending left of it get a caret at their end. */ +export function rectangleRanges(state: EditorState, a: BlockPoint, b: BlockPoint): SelectionRange[] { + const from = Math.min(a.col, b.col); + const to = Math.max(a.col, b.col); + const ranges: SelectionRange[] = []; + for (let n = Math.min(a.line, b.line); n <= Math.max(a.line, b.line); n++) { + const line = state.doc.line(n); + if (visibleWidth(line.text, state.tabSize) < from) { + ranges.push(EditorSelection.cursor(line.to)); + continue; + } + const start = offsetAtVisibleColumn(line.text, from, state.tabSize); + const end = offsetAtVisibleColumn(line.text, to, state.tabSize); + ranges.push(EditorSelection.range(line.from + start, line.from + end)); + } + return ranges; +} + +/** Mouse rectangular (block) selection in visible columns, for the drags `filter` accepts. */ +export function blockMouseSelection(filter: (e: MouseEvent) => boolean): Extension { + return EditorView.mouseSelectionStyle.of((view, event): MouseSelectionStyle | null => { + if (!filter(event)) return null; + let start = blockPointAt(view, event); + let startSel = view.state.selection; + return { + update(update) { + if (!update.docChanged) return; + const from = update.changes.mapPos(update.startState.doc.line(start.line).from); + start = { line: update.state.doc.lineAt(from).number, col: start.col }; + startSel = startSel.map(update.changes); + }, + get(e, _extend, multiple) { + const ranges = rectangleRanges(view.state, start, blockPointAt(view, e)); + if (!ranges.length) return startSel; + return EditorSelection.create(multiple ? ranges.concat(startSel.ranges) : ranges); + }, + }; + }); +} + function viewOf(e: MouseEvent): EditorView | null { return e.target instanceof HTMLElement ? EditorView.findFromDOM(e.target) : null; } export const columnModeExtension: Extension = [ columnModeField, - rectangularSelection({ - eventFilter: (e) => { - if (e.button !== 0 || e.altKey || e.metaKey || e.ctrlKey) return false; - const view = viewOf(e); - return !!view && isColumnMode(view.state); - }, + blockMouseSelection((e) => { + if (e.button !== 0 || e.altKey || e.metaKey || e.ctrlKey) return false; + const view = viewOf(e); + return !!view && isColumnMode(view.state); }), Prec.high( keymap.of([ diff --git a/src/editor/core/cm/comments.ts b/src/editor/core/cm/comments.ts index 224f2bfc..fe22cc10 100644 --- a/src/editor/core/cm/comments.ts +++ b/src/editor/core/cm/comments.ts @@ -12,6 +12,7 @@ import { import type { Command, EditorView } from "@codemirror/view"; import { indentString, syntaxTree } from "@codemirror/language"; import { toggleComment } from "@codemirror/commands"; +import { offsetAtVisibleColumn, visibleColumn } from "./visibleColumn"; /** Code Style › › Code Generation › Comment Code. */ export interface LineCommentStyle { @@ -151,9 +152,9 @@ export const commentLine: Command = (view) => { } else if (b.caretUpdate === "down") { const line = next.lineAt(r.head); if (line.number >= next.lines) continue; - const col = countColumn(next.sliceString(line.from, r.head), state.tabSize); + const col = visibleColumn(line.text, r.head - line.from, state.tabSize); const below = next.line(line.number + 1); - ranges[i] = EditorSelection.cursor(below.from + offsetAtColumn(below.text, col, state.tabSize)); + ranges[i] = EditorSelection.cursor(below.from + offsetAtVisibleColumn(below.text, col, state.tabSize)); } else { const from = next.lineAt(r.from).from; ranges[i] = r.head >= r.anchor ? EditorSelection.range(from, r.to) : EditorSelection.range(r.to, from); @@ -170,15 +171,6 @@ export const commentLine: Command = (view) => { return true; }; -function offsetAtColumn(text: string, col: number, tabSize: number): number { - let cols = 0; - for (let i = 0; i < text.length; i++) { - if (cols >= col) return i; - cols += text[i] === "\t" ? tabSize - (cols % tabSize) : 1; - } - return text.length; -} - /** Smallest indent of the non-blank lines, also no deeper than a line comment right above. */ function minIndent(state: EditorState, startLine: number, endLine: number, token: string): number { const doc = state.doc; diff --git a/src/editor/core/cm/editing.test.ts b/src/editor/core/cm/editing.test.ts index a63ec10f..8bae84dd 100644 --- a/src/editor/core/cm/editing.test.ts +++ b/src/editor/core/cm/editing.test.ts @@ -201,6 +201,13 @@ describe("comment with line comment", () => { expect(show(v)).toBe("//a\nb|\n//c\nd|\n"); }); + it("moves the caret down to the same visible column past CJK text", () => { + const v = make("张三|x\nabcdefgh\n", comments); + commentLine(v); + // "//张三" is six columns wide. + expect(show(v)).toBe("//张三x\nabcdef|gh\n"); + }); + it("keeps the caret after the prefix on an empty line", () => { const v = make("f {\n |\n b", indented); commentLine(v); diff --git a/src/editor/core/cm/multiCaret.ts b/src/editor/core/cm/multiCaret.ts index 1bd735ab..620f6ce9 100644 --- a/src/editor/core/cm/multiCaret.ts +++ b/src/editor/core/cm/multiCaret.ts @@ -1,10 +1,13 @@ /** * IDEA mouse multi-caret gestures (macOS keymap): ⌥⇧click adds a caret (or - * removes the one under the pointer), ⌥⇧drag adds a selection, ⌥drag makes a - * column (rectangular) selection. + * removes the one under the pointer), ⌥⇧drag adds a selection, ⌥drag and + * middle-button drag make a column (rectangular) selection. Middle-button + * drag is left alone on Linux, where a middle click pastes the primary selection. */ import { EditorSelection, type Extension } from "@codemirror/state"; -import { EditorView, rectangularSelection, type MouseSelectionStyle } from "@codemirror/view"; +import { EditorView, type MouseSelectionStyle } from "@codemirror/view"; +import { usePlatform } from "@/platform/platformInfo"; +import { blockMouseSelection } from "./columnMode"; /** The selection after ⌥⇧click / drag from `anchor` to `head` on top of `start`. */ export function toggleCaret(start: EditorSelection, anchor: number, head: number): EditorSelection { @@ -37,5 +40,9 @@ export const multiCaretMouse: Extension = [ addCaretStyle, // CodeMirror's default (⌘click) would clash with ⌘click navigation. EditorView.clickAddsSelectionRange.of(() => false), - rectangularSelection({ eventFilter: (e) => e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey }), + blockMouseSelection((e) => { + if (e.shiftKey || e.metaKey || e.ctrlKey) return false; + if (e.button === 0) return e.altKey; + return e.button === 1 && !e.altKey && usePlatform.getState().os !== "linux"; + }), ]; diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index cc19e6f3..d19b2a6e 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -45,6 +45,7 @@ import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapAct import { commentTokens } from "./languages"; import { multiCaretMouse } from "./multiCaret"; import { columnModeExtension } from "./columnMode"; +import { visibleGoalColumns } from "./verticalMotion"; import { tabKeymap } from "./smartKeys"; import { editorBaseKeymap } from "./baseKeymap"; import { occurrenceSearch } from "./editing"; @@ -330,6 +331,7 @@ export function baseExtensions(o: SetupOptions): Extension { EditorState.allowMultipleSelections.of(true), multiCaretMouse, columnModeExtension, + visibleGoalColumns, occurrenceSearch, overwriteField, gutterLineDrag, diff --git a/src/editor/core/cm/smartKeys.test.ts b/src/editor/core/cm/smartKeys.test.ts index eaf21b29..089405f1 100644 --- a/src/editor/core/cm/smartKeys.test.ts +++ b/src/editor/core/cm/smartKeys.test.ts @@ -72,6 +72,12 @@ describe("tab", () => { expect(show(v)).toBe("ab |c\n |x"); }); + it("counts wide characters as two columns towards the indent stop", () => { + const v = make("a张|x\n张|y"); + insertTab(v); + expect(show(v)).toBe("a张 |x\n张 |y"); + }); + it("inserts a tab character when the code style uses tabs", () => { const v = make("a|b", [], "\t"); insertTab(v); diff --git a/src/editor/core/cm/smartKeys.ts b/src/editor/core/cm/smartKeys.ts index f8d5cd87..8f978ba3 100644 --- a/src/editor/core/cm/smartKeys.ts +++ b/src/editor/core/cm/smartKeys.ts @@ -14,6 +14,7 @@ import { import { EditorView, keymap, type Command } from "@codemirror/view"; import { getIndentUnit, getIndentation, indentString, indentUnit, syntaxTree } from "@codemirror/language"; import { indentMore } from "@codemirror/commands"; +import { visibleColumn } from "./visibleColumn"; // --- Tab ------------------------------------------------------------------------------- @@ -32,7 +33,7 @@ export const insertTab: Command = (view) => { const size = Math.max(1, getIndentUnit(state)); const tr = state.changeByRange((r) => { const line = state.doc.lineAt(r.from); - const col = countColumn(line.text.slice(0, r.from - line.from), state.tabSize); + const col = visibleColumn(line.text, r.from - line.from, state.tabSize); const insert = useTab ? "\t" : " ".repeat(size - (col % size)); return { changes: { from: r.from, to: r.to, insert }, range: EditorSelection.cursor(r.from + insert.length) }; }); diff --git a/src/editor/core/cm/verticalMotion.test.ts b/src/editor/core/cm/verticalMotion.test.ts new file mode 100644 index 00000000..919d7a8a --- /dev/null +++ b/src/editor/core/cm/verticalMotion.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + cloneCaretAbove, + cloneCaretBelow, + cursorLineDown, + cursorLineUp, + selectLineDown, + visibleGoalColumns, +} from "./verticalMotion"; + +let view: EditorView | null = null; +afterEach(() => { + view?.destroy(); + view = null; +}); + +function make(doc: string, at: number) { + view = new EditorView({ + state: EditorState.create({ + doc, + selection: { anchor: at }, + extensions: [visibleGoalColumns, EditorState.allowMultipleSelections.of(true), EditorState.tabSize.of(4)], + }), + parent: document.body, + }); + return view; +} + +const heads = (v: EditorView) => v.state.selection.ranges.map((r) => r.head); +/** Offset of `needle` on line `n` (1-based) of the view's document. */ +const at = (v: EditorView, n: number, needle: string) => + v.state.doc.line(n).from + v.state.doc.line(n).text.indexOf(needle); + +// Every `|` sits at visible column 15. +const DOC = [ + 'name = "张三" |/ 中文注释', + "id = 42 |/ answer", + ",。!:()。 |/ 标点", + "😀😀 a\t |/ emoji", +].join("\n"); + +describe("vertical caret motion in visible columns", () => { + it("↓ keeps the column through CJK, fullwidth punctuation, emoji and tabs", () => { + const v = make(DOC, DOC.indexOf("|")); + for (let n = 2; n <= 4; n++) { + cursorLineDown(v); + expect(heads(v)).toEqual([at(v, n, "|")]); + } + for (let n = 3; n >= 1; n--) { + cursorLineUp(v); + expect(heads(v)).toEqual([at(v, n, "|")]); + } + }); + + it("keeps the goal column across a short line", () => { + const v = make("abcdefgh\n张\nabcdefgh", 6); + cursorLineDown(v); + expect(heads(v)).toEqual([10]); // end of "张" + cursorLineDown(v); + expect(heads(v)).toEqual([11 + 6]); + }); + + it("snaps inside a wide character to the nearer side", () => { + // Column 3 is the middle of 中 (columns 2..4) on line 2: the tie goes after it. + const v = make("abcd\n张中", 3); + cursorLineDown(v); + expect(heads(v)).toEqual([5 + 2]); + }); + + it("⇧↓ extends to the same column", () => { + const v = make("张三ab\nabcdef", 2); + selectLineDown(v); + expect(v.state.selection.main.anchor).toBe(2); + expect(v.state.selection.main.head).toBe(5 + 4); + }); + + it("moves to the line boundary on the first / last line", () => { + const v = make("ab\ncd", 1); + cursorLineUp(v); + expect(heads(v)).toEqual([0]); + }); +}); + +describe("Clone Caret Above / Below in visible columns", () => { + it("clones at the same visible column on mixed CJK / ASCII lines", () => { + const v = make(DOC, DOC.indexOf("|")); + cloneCaretBelow(v); + cloneCaretBelow(v); + cloneCaretBelow(v); + expect(heads(v)).toEqual([1, 2, 3, 4].map((n) => at(v, n, "|"))); + }); + + it("keeps the original column past a short line", () => { + const v = make("abcdefgh\nab\nabcdefgh", 18); + // Caret at column 6 of line 3; line 2 is two columns wide. + cloneCaretAbove(v); + cloneCaretAbove(v); + expect(heads(v)).toEqual([6, 11, 18]); + }); + + it("does nothing at the document edge", () => { + const v = make("张三", 1); + expect(cloneCaretAbove(v)).toBe(false); + }); +}); diff --git a/src/editor/core/cm/verticalMotion.ts b/src/editor/core/cm/verticalMotion.ts new file mode 100644 index 00000000..beb4db0d --- /dev/null +++ b/src/editor/core/cm/verticalMotion.ts @@ -0,0 +1,107 @@ +/** + * ↑ / ↓ (with ⇧ extending) and Clone Caret Above / Below in visible columns: + * the caret keeps its goal column across lines of mixed-width text (CJK, + * fullwidth punctuation, emoji, tabs) instead of a pixel x that drifts when a + * wide glyph is not exactly two cells wide. Soft-wrapped lines keep + * CodeMirror's pixel motion between their visual rows. + */ +import { EditorSelection, StateEffect, StateField, type EditorState, type SelectionRange } from "@codemirror/state"; +import type { Command, EditorView } from "@codemirror/view"; +import { offsetAtVisibleColumn, visibleColumn } from "./visibleColumn"; + +/** Goal visible column per caret head, kept while only these commands move the carets. */ +const setGoals = StateEffect.define>(); + +export const visibleGoalColumns = StateField.define | null>({ + create: () => null, + update(value, tr) { + for (const e of tr.effects) if (e.is(setGoals)) return e.value; + return tr.selection || tr.docChanged ? null : value; + }, +}); + +function goalOf(state: EditorState, head: number): number { + const goal = state.field(visibleGoalColumns, false)?.get(head); + if (goal !== undefined) return goal; + const line = state.doc.lineAt(head); + return visibleColumn(line.text, head - line.from, state.tabSize); +} + +/** Whether the line from `pos` is one visual row (not soft-wrapped, not merged with others by a fold). */ +function singleRow(view: EditorView, pos: number): boolean { + const line = view.state.doc.lineAt(pos); + const block = view.lineBlockAt(pos); + if (block.from !== line.from || block.to !== line.to) return false; + if (!view.lineWrapping || line.length === 0) return true; + const a = view.coordsAtPos(line.from, 1); + const b = view.coordsAtPos(line.to, -1); + return !!a && !!b && Math.abs(a.top - b.top) < 1; +} + +/** The head one visual line up / down from `head` at visible column `goal`, or null at the document edge. */ +export function verticalHead(view: EditorView, head: number, forward: boolean, goal: number): number | null { + const { state } = view; + const block = view.lineBlockAt(head); + if (forward ? block.to >= state.doc.length : block.from <= 0) return null; + const target = forward ? block.to + 1 : block.from - 1; + if (singleRow(view, head) && singleRow(view, target)) { + const line = state.doc.lineAt(target); + return line.from + offsetAtVisibleColumn(line.text, goal, state.tabSize); + } + const moved = view.moveVertically(EditorSelection.cursor(head), forward).head; + return moved === head ? null : moved; +} + +function moveCarets(view: EditorView, forward: boolean, extend: boolean): boolean { + const { state } = view; + const goals = new Map(); + const ranges = state.selection.ranges.map((r): SelectionRange => { + if (!extend && !r.empty) return EditorSelection.cursor(forward ? r.to : r.from); + const goal = goalOf(state, r.head); + let head = verticalHead(view, r.head, forward, goal); + if (head === null) head = forward ? state.doc.lineAt(r.head).to : state.doc.lineAt(r.head).from; + goals.set(head, goal); + return extend ? EditorSelection.range(r.anchor, head) : EditorSelection.cursor(head); + }); + const selection = EditorSelection.create(ranges, state.selection.mainIndex); + if (selection.eq(state.selection)) return false; + view.dispatch({ selection, effects: setGoals.of(goals), scrollIntoView: true, userEvent: "select" }); + return true; +} + +export const cursorLineUp: Command = (view) => moveCarets(view, false, false); +export const cursorLineDown: Command = (view) => moveCarets(view, true, false); +export const selectLineUp: Command = (view) => moveCarets(view, false, true); +export const selectLineDown: Command = (view) => moveCarets(view, true, true); + +/** Clone Caret Above / Below: a caret on the next line at each caret's goal column. */ +function cloneCaret(view: EditorView, forward: boolean): boolean { + const { state } = view; + const ranges = state.selection.ranges.slice(); + const goals = new Map(); + for (const r of state.selection.ranges) { + const goal = goalOf(state, r.head); + goals.set(r.head, goal); + const line = state.doc.lineAt(r.head); + // A soft-wrapped line: step over its own visual rows first. + let head: number | null = r.head; + while (head !== null && head >= line.from && head <= line.to) { + const next = verticalHead(view, head, forward, goal); + head = next === head ? null : next; + } + if (head === null || ranges.some((x) => x.head === head)) continue; + ranges.push(EditorSelection.cursor(head)); + goals.set(head, goal); + } + if (ranges.length === state.selection.ranges.length) return false; + view.dispatch({ + selection: EditorSelection.create(ranges, ranges.length - 1), + effects: setGoals.of(goals), + scrollIntoView: true, + userEvent: "select", + }); + return true; +} + +export const cloneCaretAbove: Command = (view) => cloneCaret(view, false); +export const cloneCaretBelow: Command = (view) => cloneCaret(view, true); diff --git a/src/editor/core/cm/visibleColumn.test.ts b/src/editor/core/cm/visibleColumn.test.ts new file mode 100644 index 00000000..b4c8b8a8 --- /dev/null +++ b/src/editor/core/cm/visibleColumn.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { findClusterBreak } from "@codemirror/state"; +import { + clusterWidth, + codePointWidth, + offsetAtVisibleColumn, + stepVisibleColumn, + visibleColumn, + visibleWidth, +} from "./visibleColumn"; + +const WIDTHS: [label: string, text: string, width: number][] = [ + ["ASCII", "id = 42", 7], + ["CJK ideographs", "张三", 4], + ["mixed line", 'name = "张三" // 中文注释', 26], + ["fullwidth punctuation", ",。!:()", 12], + ["ideographic space", "a b", 4], + ["fullwidth Latin", "AB1", 6], + ["halfwidth katakana", "アイ", 2], + ["hiragana / katakana", "かなカナ", 8], + ["Hangul", "한글", 4], + ["CJK Ext B", "𠀀", 2], + ["emoji", "😀", 2], + ["emoji ZWJ sequence", "👨‍👩‍👧", 2], + ["emoji with skin tone", "👍🏽", 2], + ["flag", "🇨🇳", 2], + ["text-style pictograph", "❤", 1], + ["pictograph + VS16", "❤️", 2], + ["combining acute", "é", 1], + ["several combining marks", "à̖b", 2], + ["zero-width space", "a​b", 2], + ["ambiguous arrow", "→", 1], + ["tab", "\t", 4], + ["tab after ASCII", "a\tb", 5], + ["tab after CJK", "张\tb", 5], + ["tab after 4 columns of CJK", "张三\tb", 9], + ["empty", "", 0], +]; + +describe("visible columns", () => { + it.each(WIDTHS)("%s", (_label, text, width) => { + expect(visibleWidth(text, 4)).toBe(width); + }); + + it.each(WIDTHS)("round-trips every cluster boundary: %s", (_label, text) => { + for (let i = 0; ; i = findClusterBreak(text, i)) { + const col = visibleColumn(text, i, 4); + const back = offsetAtVisibleColumn(text, col, 4); + // A zero-width cluster shares its column with the boundary before it. + if (clusterWidth(text, back, i) === 0) expect(back).toBeLessThanOrEqual(i); + else expect(back).toBe(i); + if (i >= text.length) break; + } + }); + + it("code points", () => { + expect(codePointWidth(0x41)).toBe(1); + expect(codePointWidth(0x4e2d)).toBe(2); + expect(codePointWidth(0xff0c)).toBe(2); + expect(codePointWidth(0x0301)).toBe(0); + expect(codePointWidth(0x200d)).toBe(0); + expect(codePointWidth(0x1f600)).toBe(2); + expect(clusterWidth("é")).toBe(1); + }); + + it("maps a column inside a wide character to the nearer side", () => { + // "a张b": a [0,1) 张 [1,3) b [3,4); UTF-16 offsets 0, 1, 2, 3. + const t = "a张b"; + expect([0, 1, 2, 3, 4].map((c) => offsetAtVisibleColumn(t, c, 4))).toEqual([0, 1, 2, 2, 3]); + expect(offsetAtVisibleColumn(t, 2, 4, "before")).toBe(1); + expect(offsetAtVisibleColumn(t, 2, 4, "after")).toBe(2); + expect(offsetAtVisibleColumn(t, 9, 4)).toBe(3); + expect(offsetAtVisibleColumn(t, 9, 4, "nearest", true)).toBe(-1); + }); + + it("returns UTF-16 offsets around astral characters", () => { + // "a😀b": the emoji is two code units and two columns. + expect(offsetAtVisibleColumn("a😀b", 3, 4)).toBe(3); + expect(visibleColumn("a😀b", 3, 4)).toBe(3); + expect(offsetAtVisibleColumn("a👨‍👩‍👧b", 3, 4)).toBe(1 + "👨‍👩‍👧".length); + }); + + it("steps one cluster at a time, then one column past the end", () => { + const t = "a张\tb"; + // Columns: a 0, 张 1..3, tab 3..4, b 4..5. + expect(stepVisibleColumn(t, 0, 1, 4)).toBe(1); + expect(stepVisibleColumn(t, 1, 1, 4)).toBe(3); + expect(stepVisibleColumn(t, 3, 1, 4)).toBe(4); + expect(stepVisibleColumn(t, 5, 1, 4)).toBe(6); + expect(stepVisibleColumn(t, 6, -1, 4)).toBe(5); + expect(stepVisibleColumn(t, 3, -1, 4)).toBe(1); + expect(stepVisibleColumn(t, 2, -1, 4)).toBe(1); + expect(stepVisibleColumn(t, 1, -1, 4)).toBe(0); + }); +}); diff --git a/src/editor/core/cm/visibleColumn.ts b/src/editor/core/cm/visibleColumn.ts new file mode 100644 index 00000000..a2a72080 --- /dev/null +++ b/src/editor/core/cm/visibleColumn.ts @@ -0,0 +1,127 @@ +/** + * Visible columns: the column model of every column-based editing operation + * (block selection, clone caret, ↑/↓ goal column, caret column after + * commenting, Tab stops). East Asian Wide / Fullwidth characters and emoji + * count 2, combining marks and other zero-width characters 0, a tab runs to + * the next tab stop, everything else 1. Measured per grapheme cluster, so a + * ZWJ emoji sequence or a base letter with accents is one unit. + * + * UI-side only: offsets in and out are UTF-16 offsets into the line text. + */ +import { findClusterBreak } from "@codemirror/state"; + +// East Asian Width W / F ranges outside emoji (Unicode 15.1), sorted, inclusive. +// prettier-ignore +const WIDE: readonly number[] = [ + 0x1100, 0x115f, 0x2329, 0x232a, 0x2e80, 0x303e, 0x3041, 0x33ff, 0x3400, 0x4dbf, 0x4e00, 0x9fff, + 0xa000, 0xa4cf, 0xa960, 0xa97f, 0xac00, 0xd7a3, 0xf900, 0xfaff, 0xfe10, 0xfe19, 0xfe30, 0xfe6f, + 0xff00, 0xff60, 0xffe0, 0xffe6, 0x16fe0, 0x16fe4, 0x16ff0, 0x16ff1, 0x17000, 0x18cff, 0x18d00, 0x18d08, + 0x1aff0, 0x1b2ff, 0x1f200, 0x1f202, 0x1f210, 0x1f23b, 0x1f240, 0x1f248, 0x1f250, 0x1f251, 0x1f260, 0x1f265, + 0x20000, 0x2fffd, 0x30000, 0x3fffd, +]; + +const ZERO = /[\p{Mn}\p{Me}\p{Cf}ᅠ-ᇿ]/u; +const EMOJI = /\p{Emoji_Presentation}/u; +const PICTOGRAPHIC = /\p{Extended_Pictographic}/u; + +function inWide(cp: number): boolean { + let lo = 0; + let hi = WIDE.length / 2 - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (cp < WIDE[mid * 2]!) hi = mid - 1; + else if (cp > WIDE[mid * 2 + 1]!) lo = mid + 1; + else return true; + } + return false; +} + +/** Columns one code point takes on its own (a tab is not handled here). */ +export function codePointWidth(cp: number): 0 | 1 | 2 { + if (cp < 0x300) return 1; + const ch = String.fromCodePoint(cp); + if (ZERO.test(ch)) return 0; + if (inWide(cp) || EMOJI.test(ch)) return 2; + return 1; +} + +/** Columns one grapheme cluster takes: its widest code point, 2 for a pictograph in emoji presentation (U+FE0F). */ +export function clusterWidth(text: string, from = 0, to = text.length): number { + let width = 0; + let pictographic = false; + for (let i = from; i < to;) { + const cp = text.codePointAt(i)!; + if (cp === 0xfe0f && pictographic) return 2; + if (i === from) pictographic = PICTOGRAPHIC.test(String.fromCodePoint(cp)); + width = Math.max(width, codePointWidth(cp)); + if (width === 2) return 2; + i += cp > 0xffff ? 2 : 1; + } + return width; +} + +/** Width of the cluster starting at `i` (at visible column `col`) and where it ends. */ +function step(text: string, i: number, col: number, tabSize: number): [width: number, next: number] { + const c = text.charCodeAt(i); + if (c === 9) return [tabSize - (col % tabSize), i + 1]; + // Plain ASCII never extends a cluster except before a combining mark / ZWJ / variation selector. + const next = c < 0x80 && (i + 1 >= text.length || text.charCodeAt(i + 1) < 0x300) ? i + 1 : findClusterBreak(text, i); + return [c >= 0x20 && c < 0x7f ? 1 : clusterWidth(text, i, next), next]; +} + +/** The visible column at UTF-16 `offset` of `text` (a cluster `offset` falls inside counts whole). */ +export function visibleColumn(text: string, offset: number, tabSize: number): number { + let col = 0; + for (let i = 0; i < offset && i < text.length;) { + const [w, next] = step(text, i, col, tabSize); + col += w; + i = next; + } + return col; +} + +/** The visible width of `text`. */ +export function visibleWidth(text: string, tabSize: number): number { + return visibleColumn(text, text.length, tabSize); +} + +export type ColumnBias = "nearest" | "before" | "after"; + +/** + * The UTF-16 offset in `text` at visible column `col`. A column inside a wide + * character or a tab snaps to the nearer side (a tie goes after) unless `bias` + * says otherwise. Past the end: `text.length`, or -1 when `strict`. + */ +export function offsetAtVisibleColumn( + text: string, + col: number, + tabSize: number, + bias: ColumnBias = "nearest", + strict = false, +): number { + let n = 0; + for (let i = 0; ;) { + if (n >= col) return i; + if (i >= text.length) break; + const [w, next] = step(text, i, n, tabSize); + if (n + w > col) { + if (bias === "before") return i; + if (bias === "after") return next; + return col - n < n + w - col ? i : next; + } + n += w; + i = next; + } + return strict ? -1 : text.length; +} + +/** The visible column one cluster left / right of `col` in `text`; past the line end, one column. */ +export function stepVisibleColumn(text: string, col: number, d: number, tabSize: number): number { + if (!d) return col; + const width = visibleWidth(text, tabSize); + if (d > 0 ? col >= width : col > width) return Math.max(0, col + d); + if (d > 0) return visibleColumn(text, offsetAtVisibleColumn(text, col + 1, tabSize, "after"), tabSize); + const pos = offsetAtVisibleColumn(text, col, tabSize, "before"); + if (visibleColumn(text, pos, tabSize) < col) return visibleColumn(text, pos, tabSize); + return pos === 0 ? 0 : visibleColumn(text, offsetAtVisibleColumn(text, col - 1, tabSize, "before"), tabSize); +} diff --git a/src/features/macros/editorKeys.ts b/src/features/macros/editorKeys.ts index 3f140fef..a413a111 100644 --- a/src/features/macros/editorKeys.ts +++ b/src/features/macros/editorKeys.ts @@ -11,8 +11,6 @@ import { cursorGroupRight, cursorLineBoundaryBackward, cursorLineBoundaryForward, - cursorLineDown, - cursorLineUp, cursorPageDown, cursorPageUp, deleteCharBackward, @@ -30,12 +28,11 @@ import { selectGroupRight, selectLineBoundaryBackward, selectLineBoundaryForward, - selectLineDown, - selectLineUp, selectPageDown, selectPageUp, } from "@codemirror/commands"; import type { Command } from "@codemirror/view"; +import { cursorLineDown, cursorLineUp, selectLineDown, selectLineUp } from "@/editor/core/cm/verticalMotion"; /** Recorded key name (`Shift+ArrowLeft`, `Alt+Backspace`) → command. */ export const EDITOR_KEY_COMMANDS: Record = {