From 36e500abfcece0e55b8567b4f2ca6bb3bad9fc22 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 | 20 +- src/cursor-doc/model.ts | 42 ++- src/cursor-doc/paredit.ts | 288 ++++++++++++------ src/doc-mirror/index.ts | 44 ++- .../unit/cursor-doc/paredit-test.ts | 12 +- 5 files changed, 274 insertions(+), 132 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2863d308c..40e03d28a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -58,6 +58,7 @@ "docmirror", "Docstring", "Dorg", + "doseq", "dotimes", "Dvlaaad", "eckstein", @@ -205,5 +206,20 @@ ], "allowCompoundWords": false } - ] -} \ No newline at end of file + ], + "peacock.color": "#e48141", + "typescript.tsdk": "node_modules/typescript/lib", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "workbench.colorCustomizations": { + "sash.hoverBorder": "#ea9f6e", + "titleBar.activeBackground": "#e48141", + "titleBar.activeForeground": "#15202b", + "titleBar.inactiveBackground": "#e4814199", + "titleBar.inactiveForeground": "#15202b99" + }, +} diff --git a/src/cursor-doc/model.ts b/src/cursor-doc/model.ts index 59e9dcf8c..2a7925ca5 100644 --- a/src/cursor-doc/model.ts +++ b/src/cursor-doc/model.ts @@ -1,7 +1,7 @@ -import { Scanner, Token, ScannerState } from './clojure-lexer'; -import { LispTokenCursor } from './token-cursor'; +import { isUndefined, max, min } from 'lodash'; import { deepEqual as equal } from '../util/object'; -import { isUndefined } from 'lodash'; +import { Scanner, ScannerState, Token } from './clojure-lexer'; +import { LispTokenCursor } from './token-cursor'; let scanner: Scanner; @@ -84,6 +84,7 @@ export type ModelEditOptions = { undoStopBefore?: boolean; formatDepth?: number; skipFormat?: boolean; + selections?: ModelEditSelection[]; selection?: ModelEditSelection; }; @@ -107,11 +108,14 @@ export interface EditableDocument { readonly selectionLeft: number; readonly selectionRight: number; 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; } @@ -548,6 +552,8 @@ export class StringDocument implements EditableDocument { model: LineInputModel = new LineInputModel(1, this); + selectionsStack: ModelEditSelection[][] = []; + selections: ModelEditSelection[] = []; selectionStack: ModelEditSelection[] = []; getTokenCursor(offset?: number, previous?: boolean): LispTokenCursor { @@ -558,23 +564,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.selectionLeft; - 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.selectionLeft; - 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 bbb395642..4ce4aee8f 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -1,7 +1,8 @@ -import { includes } from 'lodash'; +import { isArray, isEqual, isNumber, last, pick, property } from 'lodash'; import { validPair } from './clojure-lexer'; -import { ModelEdit, EditableDocument, ModelEditSelection } from './model'; +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. @@ -33,52 +34,67 @@ 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 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) { @@ -824,85 +840,159 @@ 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, - start: number = doc.selectionLeft, - end: number = doc.selectionRight + doc: EditableDocument, + // start: number = doc.selectionLeft, + // end: number = doc.selectionRight ) { - 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.selectionLeft == prev.anchor && doc.selectionRight == 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.selectionLeft && - latest.active == doc.selectionRight - ) { - 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 d33cd8986..f923711d7 100644 --- a/src/doc-mirror/index.ts +++ b/src/doc-mirror/index.ts @@ -1,17 +1,17 @@ export { getIndent } from '../cursor-doc/indent'; +import { isUndefined } from 'lodash'; import * as vscode from 'vscode'; -import * as utilities from '../utilities'; import * as formatter from '../calva-fmt/src/format'; -import { LispTokenCursor } from '../cursor-doc/token-cursor'; import { - ModelEdit, EditableDocument, EditableModel, - ModelEditOptions, LineInputModel, + ModelEdit, + ModelEditOptions, ModelEditSelection, } from '../cursor-doc/model'; -import { isUndefined } from 'lodash'; +import { LispTokenCursor } from '../cursor-doc/token-cursor'; +import * as utilities from '../utilities'; const documents = new Map(); @@ -121,8 +121,17 @@ 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.getActiveTextEditor().selection.anchor); + return this.document.offsetAt(utilities.tryToGetActiveTextEditor().selection.anchor); } get selectionRight(): number { @@ -131,21 +140,24 @@ export class MirroredDocument implements EditableDocument { model = new DocumentModel(this); + selectionsStack: ModelEditSelection[][] = []; selectionStack: ModelEditSelection[] = []; public getTokenCursor( - offset: number = this.selectionRight, + 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(), - edit = vscode.TextEdit.insert(this.document.positionAt(this.selectionLeft), 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; }); @@ -164,12 +176,18 @@ export class MirroredDocument implements EditableDocument { return new ModelEditSelection(this.selectionLeft, this.selectionRight); } - 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 c0ef206a1..86932d5ec 100644 --- a/src/extension-test/unit/cursor-doc/paredit-test.ts +++ b/src/extension-test/unit/cursor-doc/paredit-test.ts @@ -586,29 +586,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])