From 87c6d4910c6503ddd28702ebd4611c08e29e90b8 Mon Sep 17 00:00:00 2001 From: Rayat Date: Sat, 19 Mar 2022 15:36:25 -0700 Subject: [PATCH] Multi Cursor for paredit Expand selection and shrink selection Fixes #610 --- .vscode/settings.json | 6 +- src/cursor-doc/model.ts | 38 ++- src/cursor-doc/paredit.ts | 280 ++++++++++++------ src/doc-mirror/index.ts | 40 ++- .../unit/cursor-doc/paredit-test.ts | 12 +- 5 files changed, 258 insertions(+), 118 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8a27c1afc..899767fe8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -203,7 +203,11 @@ ], "cSpell.languageSettings": [ { - "languageId": ["clojure", "json", "typescript"], + "languageId": [ + "clojure", + "json", + "typescript" + ], "allowCompoundWords": false } ], diff --git a/src/cursor-doc/model.ts b/src/cursor-doc/model.ts index 97699423b..8c8340ca2 100644 --- a/src/cursor-doc/model.ts +++ b/src/cursor-doc/model.ts @@ -1,4 +1,4 @@ -import { isUndefined } from 'lodash'; +import { isUndefined, max, min } from 'lodash'; import { deepEqual as equal } from '../util/object'; import { Scanner, ScannerState, Token } from './clojure-lexer'; import { LispTokenCursor } from './token-cursor'; @@ -84,6 +84,7 @@ export type ModelEditOptions = { undoStopBefore?: boolean; formatDepth?: number; skipFormat?: boolean; + selections?: ModelEditSelection[]; selection?: ModelEditSelection; }; @@ -105,11 +106,14 @@ export interface EditableModel { export interface EditableDocument { selection: ModelEditSelection; + selections: ModelEditSelection[]; model: EditableModel; + selectionsStack: ModelEditSelection[][]; selectionStack: ModelEditSelection[]; getTokenCursor: (offset?: number, previous?: boolean) => LispTokenCursor; insertString: (text: string) => void; - getSelectionText: () => string; + getSelectionText: (index?: number) => string; + getSelectionsText: () => string[]; delete: () => Thenable; backspace: () => Thenable; } @@ -536,6 +540,8 @@ export class StringDocument implements EditableDocument { model: LineInputModel = new LineInputModel(1, this); + selectionsStack: ModelEditSelection[][] = []; + selections: ModelEditSelection[] = []; selectionStack: ModelEditSelection[] = []; getTokenCursor(offset?: number, previous?: boolean): LispTokenCursor { @@ -546,23 +552,35 @@ export class StringDocument implements EditableDocument { return this.model.getTokenCursor(offset); } + getSelectionsText: () => string[]; insertString(text: string) { this.model.insertString(0, text); } - getSelectionText: () => string; - delete() { - const p = this.selection.anchor; - return this.model.edit([new ModelEdit('deleteRange', [p, 1])], { - selection: new ModelEditSelection(p), + const edits = []; + const selections = []; + this.selections.forEach(({ anchor: p }) => { + edits.push(new ModelEdit('deleteRange', [p, 1])); + selections.push(new ModelEditSelection(p)); + }); + + return this.model.edit(edits, { + selections, }); } + getSelectionText: () => string; backspace() { - const p = this.selection.anchor; - return this.model.edit([new ModelEdit('deleteRange', [p - 1, 1])], { - selection: new ModelEditSelection(p - 1), + const edits = []; + const selections = []; + this.selections.forEach(({ anchor: p }) => { + edits.push(new ModelEdit('deleteRange', [p - 1, 1])); + selections.push(new ModelEditSelection(p - 1)); + }); + + return this.model.edit(edits, { + selections, }); } } diff --git a/src/cursor-doc/paredit.ts b/src/cursor-doc/paredit.ts index 1175092b7..96bf4424f 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -1,6 +1,8 @@ +import { isArray, isEqual, isNumber, last, pick, property } from 'lodash'; import { validPair } from './clojure-lexer'; import { EditableDocument, ModelEdit, ModelEditSelection } from './model'; import { LispTokenCursor } from './token-cursor'; +import _ = require('lodash'); // NB: doc.model.edit returns a Thenable, so that the vscode Editor can compose commands. // But don't put such chains in this module because that won't work in the repl-console. @@ -32,36 +34,48 @@ export function moveToRangeRight(doc: EditableDocument, range: [number, number]) doc.selection = new ModelEditSelection(Math.max(range[0], range[1])); } -export function selectRange(doc: EditableDocument, range: [number, number]) { - growSelectionStack(doc, range); +export function selectRange(doc: EditableDocument, range: [number, number] | Array) { + if (isArray(range[0])) { + growSelectionStack(doc, range as Array); + } else if (range.length === 2 && isNumber(range[0])) { + growSelectionStack(doc, [range as [number, number]]); + } } -export function selectRangeForward(doc: EditableDocument, range: [number, number]) { - const selectionLeft = doc.selection.anchor; - const rangeRight = Math.max(range[0], range[1]); - growSelectionStack(doc, [selectionLeft, rangeRight]); +export function selectRangeForward( + doc: EditableDocument, + range: [number, number] +) { + const selectionLeft = doc.selection.anchor; + const rangeRight = Math.max(range[0], range[1]); + growSelectionStack(doc, [[selectionLeft, rangeRight]]); } -export function selectRangeBackward(doc: EditableDocument, range: [number, number]) { - const selectionRight = doc.selection.anchor; - const rangeLeft = Math.min(range[0], range[1]); - growSelectionStack(doc, [selectionRight, rangeLeft]); +export function selectRangeBackward( + doc: EditableDocument, + range: [number, number] +) { + const selectionRight = doc.selection.anchor; + const rangeLeft = Math.min(range[0], range[1]); + growSelectionStack(doc, [[selectionRight, rangeLeft]]); } export function selectForwardSexp(doc: EditableDocument) { - const rangeFn = - doc.selection.active >= doc.selection.anchor - ? forwardSexpRange - : (doc: EditableDocument) => forwardSexpRange(doc, doc.selection.active, true); - selectRangeForward(doc, rangeFn(doc)); + const rangeFn = + doc.selection.active >= doc.selection.anchor + ? forwardSexpRange + : (doc: EditableDocument) => + forwardSexpRange(doc, doc.selection.active, true); + selectRangeForward(doc, rangeFn(doc)); } export function selectRight(doc: EditableDocument) { - const rangeFn = - doc.selection.active >= doc.selection.anchor - ? forwardHybridSexpRange - : (doc: EditableDocument) => forwardHybridSexpRange(doc, doc.selection.active, true); - selectRangeForward(doc, rangeFn(doc)); + const rangeFn = + doc.selection.active >= doc.selection.anchor + ? forwardHybridSexpRange + : (doc: EditableDocument) => + forwardHybridSexpRange(doc, doc.selection.active, true); + selectRangeForward(doc, rangeFn(doc)); } export function selectForwardSexpOrUp(doc: EditableDocument) { @@ -74,19 +88,22 @@ export function selectForwardSexpOrUp(doc: EditableDocument) { } export function selectBackwardSexp(doc: EditableDocument) { - const rangeFn = - doc.selection.active <= doc.selection.anchor - ? backwardSexpRange - : (doc: EditableDocument) => backwardSexpRange(doc, doc.selection.active, false); - selectRangeBackward(doc, rangeFn(doc)); + const rangeFn = + doc.selection.active <= doc.selection.anchor + ? backwardSexpRange + : (doc: EditableDocument) => + backwardSexpRange(doc, doc.selection.active, false); + selectRangeBackward(doc, rangeFn(doc)); } export function selectForwardDownSexp(doc: EditableDocument) { - const rangeFn = - doc.selection.active >= doc.selection.anchor - ? (doc: EditableDocument) => rangeToForwardDownList(doc, doc.selection.active, true) - : (doc: EditableDocument) => rangeToForwardDownList(doc, doc.selection.active, true); - selectRangeForward(doc, rangeFn(doc)); + const rangeFn = + doc.selection.active >= doc.selection.anchor + ? (doc: EditableDocument) => + rangeToForwardDownList(doc, doc.selection.active, true) + : (doc: EditableDocument) => + rangeToForwardDownList(doc, doc.selection.active, true); + selectRangeForward(doc, rangeFn(doc)); } export function selectBackwardDownSexp(doc: EditableDocument) { @@ -901,85 +918,160 @@ export function stringQuote( } } +/** + * Given the set of selections in the given document, + * edit the set of selections therein such that for each selection: + * the selection expands to encompass just the contents of the form + * (where this selection or its cursor lies), the entire form itself + * (including open/close symbols) or the full contents of the form + * immediately enclosing this one, repeating each time the function is + * called, for each selection in the doc. + * + * (Or in other words, the S-expression powered equivalent to vs-code's + * built-in Expand Selection/Shrink Selection commands) + */ export function growSelection( + doc: EditableDocument, doc: EditableDocument, start: number = doc.selection.anchor, end: number = doc.selection.active ) { - const startC = doc.getTokenCursor(start), - endC = doc.getTokenCursor(end), - emptySelection = startC.equals(endC); - - if (emptySelection) { - const currentFormRange = startC.rangeForCurrentForm(start); - if (currentFormRange) { - growSelectionStack(doc, currentFormRange); - } - } else { - if (startC.getPrevToken().type == 'open' && endC.getToken().type == 'close') { - startC.backwardList(); - startC.backwardUpList(); - endC.forwardList(); - growSelectionStack(doc, [startC.offsetStart, endC.offsetEnd]); - } else { - if (startC.backwardList()) { - // we are in an sexpr. - endC.forwardList(); - endC.previous(); - } else { - if (startC.backwardDownList()) { - startC.backwardList(); - if (emptySelection) { - endC.set(startC); - endC.forwardList(); - endC.next(); - } - startC.previous(); - } else if (startC.downList()) { - if (emptySelection) { - endC.set(startC); - endC.forwardList(); - endC.next(); - } - startC.previous(); + const newRanges = doc.selections.map(({ anchor: start, active: end }) => { + // init start/end TokenCursors, ascertain emptiness of selection + const startC = doc.getTokenCursor(start), + endC = doc.getTokenCursor(end), + emptySelection = startC.equals(endC); + + // check if selection is empty - means just a cursor + if (emptySelection) { + const currentFormRange = startC.rangeForCurrentForm(start); + // check if there's a form associated with the current cursor + if (currentFormRange) { + // growSelectionStack(doc, currentFormRange); + return currentFormRange; + } + // if there's not, do nothing, we will not be expanding this cursor + return [start, end] as const; + } else { + if ( + startC.getPrevToken().type == 'open' && + endC.getToken().type == 'close' + ) { + startC.backwardList(); + startC.backwardUpList(); + endC.forwardList(); + // growSelectionStack(doc, [startC.offsetStart, endC.offsetEnd]); + return [startC.offsetStart, startC.offsetEnd] as const; + } else { + if (startC.backwardList()) { + // we are in an sexpr. + endC.forwardList(); + endC.previous(); + } else { + if (startC.backwardDownList()) { + startC.backwardList(); + if (emptySelection) { + endC.set(startC); + endC.forwardList(); + endC.next(); + } + startC.previous(); + } else if (startC.downList()) { + if (emptySelection) { + endC.set(startC); + endC.forwardList(); + endC.next(); + } + startC.previous(); + } + } + // growSelectionStack(doc, [startC.offsetStart, endC.offsetEnd]); + return [startC.offsetStart, endC.offsetEnd] as const; + } } - } - growSelectionStack(doc, [startC.offsetStart, endC.offsetEnd]); - } - } + }) + growSelectionStack(doc, newRanges); } -export function growSelectionStack(doc: EditableDocument, range: [number, number]) { - const [start, end] = range; - if (doc.selectionStack.length > 0) { - const prev = doc.selectionStack[doc.selectionStack.length - 1]; - if (!(doc.selection.anchor == prev.anchor && doc.selection.active == prev.active)) { - setSelectionStack(doc); - } else if (prev.anchor === range[0] && prev.active === range[1]) { - return; +/** + * When growing a stack, we want a temporary selection undo/redo history, + * so to speak, such that each time the growSelection (labelled "Expand Selection") + * command is invoked, the selection keeps expanding by s-expression level, without + * "losing" prior selections. + * + * Recall that one cannot arbitrarily "Shrink Selection" _without_ such a history stack + * or outside of the context of a series of "Expand Selection" operations, + * as there is no way to know to which symbol(s) a user could hypothetically intend on + * "zooming in" on via selection shrinking. + * + * Thus to implement the above, we store a stack of selections (each item in the stack + * is an array of selection items, one per cursor) and grow or traverse this stack + * as the user expands or shrinks their selection(s). + * + * @param doc EditableDocument + * @param ranges the new ranges to grow the selection into + * @returns + */ +export function growSelectionStack( + doc: EditableDocument, + ranges: Array<(readonly [number, number])>, +) { + // const [start, end] = range; + // Check if user has already at least once invoked "Expand Selection": + if (doc.selectionsStack.length > 0) { + // User indeed already has a selection set expansion history. + const prev = last(doc.selectionsStack); + // Check if the current document selection set DOES NOT match the widest (latest) selection set + // in the history. + if ( + !( + isEqual(doc.selections.map(property('anchor')), prev.map(property('anchor'))) && + isEqual(doc.selections.map(property('active')), prev.map(property('active'))) + ) + ) { + // FIXME(multi-cursor): This means there's some kind of mismatch. Why? + // Therefore, let's reset the selection set history + setSelectionStack(doc); + + // Check if the intended new selection set to grow into is already the widest (latest) selection set + // in the history. + } else if ( + isEqual(prev.map(property('anchor')), ranges.map(property(0))) && + isEqual(prev.map(property('active')), ranges.map(property(1)))) { + return; + } + } else { + // start a "fresh" selection set expansion history + // FIXME(multi-cursor): why doesn't this use `setSelectionStack(doc)` from below? + doc.selectionsStack = [doc.selections]; } - } else { - doc.selectionStack = [doc.selection]; - } - doc.selection = new ModelEditSelection(start, end); - doc.selectionStack.push(doc.selection); + doc.selections = ranges.map((range) => new ModelEditSelection(...range)); + doc.selectionsStack.push(doc.selections); } +// FIXME(multi-cursor): prob needs rethinking export function shrinkSelection(doc: EditableDocument) { - if (doc.selectionStack.length) { - const latest = doc.selectionStack.pop(); - if ( - doc.selectionStack.length && - latest.anchor == doc.selection.anchor && - latest.active == doc.selection.active - ) { - doc.selection = doc.selectionStack[doc.selectionStack.length - 1]; + if (doc.selectionsStack.length) { + const latest = doc.selectionsStack.pop(); + if ( + doc.selectionsStack.length && + latest + .every((selection, index) => isEqual( + pick(selection, ['anchor, active']), + pick(doc.selections[index], ['anchor, active']) + )) + ) { + doc.selections = last(doc.selectionsStack); + } } - } + } -export function setSelectionStack(doc: EditableDocument, selection = doc.selection) { - doc.selectionStack = [selection]; +export function setSelectionStack( + doc: EditableDocument, + selections: ModelEditSelection[] = doc.selections +) { + doc.selectionsStack = [selections]; } export function raiseSexp( diff --git a/src/doc-mirror/index.ts b/src/doc-mirror/index.ts index f8402f650..4c9c08329 100644 --- a/src/doc-mirror/index.ts +++ b/src/doc-mirror/index.ts @@ -122,24 +122,44 @@ export class DocumentModel implements EditableModel { export class MirroredDocument implements EditableDocument { constructor(public document: vscode.TextDocument) {} + get selections() { + return utilities + .tryToGetActiveTextEditor() + .selections.map( + ({ anchor, active }) => + new ModelEditSelection(this.document.offsetAt(anchor), this.document.offsetAt(active)) + ); + } + + get selectionLeft(): number { + return this.document.offsetAt(utilities.tryToGetActiveTextEditor().selection.anchor); + } + + get selectionRight(): number { + return this.document.offsetAt(utilities.getActiveTextEditor().selection.active); + } + model = new DocumentModel(this); + selectionsStack: ModelEditSelection[][] = []; selectionStack: ModelEditSelection[] = []; public getTokenCursor( - offset: number = this.selection.active, + offset: number = this.selections[0].active, previous: boolean = false ): LispTokenCursor { return this.model.getTokenCursor(offset, previous); } public insertString(text: string) { - const editor = utilities.getActiveTextEditor(), + const editor = utilities.tryToGetActiveTextEditor(), selection = editor.selection, wsEdit = new vscode.WorkspaceEdit(), // TODO: prob prefer selection.active or .start - edit = vscode.TextEdit.insert(this.document.positionAt(this.selection.anchor), text); - wsEdit.set(this.document.uri, [edit]); + edits = this.selections.map(({ anchor: left }) => + vscode.TextEdit.insert(this.document.positionAt(left), text) + ); + wsEdit.set(this.document.uri, edits); void vscode.workspace.applyEdit(wsEdit).then((_v) => { editor.selection = selection; }); @@ -162,12 +182,18 @@ export class MirroredDocument implements EditableDocument { return new ModelEditSelection(anchor, active); } - public getSelectionText() { - const editor = utilities.getActiveTextEditor(), - selection = editor.selection; + public getSelectionText(index: number = 0) { + const editor = utilities.tryToGetActiveTextEditor(), + selection = editor.selections[index]; return this.document.getText(selection); } + public getSelectionsText() { + const editor = utilities.tryToGetActiveTextEditor(), + selections = editor.selections; + return selections.map((s) => this.document.getText(s)); + } + public delete(): Thenable { return vscode.commands.executeCommand('deleteRight'); } diff --git a/src/extension-test/unit/cursor-doc/paredit-test.ts b/src/extension-test/unit/cursor-doc/paredit-test.ts index eff0299dc..9ff824669 100644 --- a/src/extension-test/unit/cursor-doc/paredit-test.ts +++ b/src/extension-test/unit/cursor-doc/paredit-test.ts @@ -693,29 +693,29 @@ describe('paredit', () => { describe('selection stack', () => { const range = [15, 20] as [number, number]; it('should make grow selection the topmost element on the stack', () => { - paredit.growSelectionStack(doc, range); + paredit.growSelectionStack(doc, [range]); expect(doc.selectionStack[doc.selectionStack.length - 1]).toEqual( new ModelEditSelection(range[0], range[1]) ); }); it('get us back to where we started if we just grow, then shrink', () => { const selectionBefore = startSelection.clone(); - paredit.growSelectionStack(doc, range); + paredit.growSelectionStack(doc, [range]); paredit.shrinkSelection(doc); expect(doc.selectionStack[doc.selectionStack.length - 1]).toEqual(selectionBefore); }); it('should not add selections identical to the topmost', () => { const selectionBefore = doc.selection.clone(); - paredit.growSelectionStack(doc, range); - paredit.growSelectionStack(doc, range); + paredit.growSelectionStack(doc, [range]); + paredit.growSelectionStack(doc, [range]); paredit.shrinkSelection(doc); expect(doc.selectionStack[doc.selectionStack.length - 1]).toEqual(selectionBefore); }); it('should have A topmost after adding A, then B, then shrinking', () => { const a = range, b: [number, number] = [10, 24]; - paredit.growSelectionStack(doc, a); - paredit.growSelectionStack(doc, b); + paredit.growSelectionStack(doc, [a]); + paredit.growSelectionStack(doc, [b]); paredit.shrinkSelection(doc); expect(doc.selectionStack[doc.selectionStack.length - 1]).toEqual( new ModelEditSelection(a[0], a[1])