Skip to content

Commit 6c5a74a

Browse files
committed
Create exported functions for killForward/BackwardList/Sexp, fix multi
1 parent 071b4d2 commit 6c5a74a

File tree

3 files changed

+314
-117
lines changed

3 files changed

+314
-117
lines changed

src/cursor-doc/paredit.ts

Lines changed: 227 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isEqual, last, pick, property, clone, isBoolean, orderBy } from 'lodash';
2+
import _ = require('lodash');
23
import { validPair } from './clojure-lexer';
34
import {
45
EditableDocument,
@@ -20,15 +21,60 @@ import { replaceAt } from '../util/array';
2021
// Example: paredit.moveToRangeRight(this.readline, paredit.forwardSexpRange(this.readline))
2122
// => paredit.moveForwardSexp(this.readline)
2223

23-
export function killRange(
24-
doc: EditableDocument,
25-
range: [number, number],
26-
start = doc.selections[0].anchor,
27-
end = doc.selections[0].active
28-
) {
29-
const [left, right] = [Math.min(...range), Math.max(...range)];
30-
void doc.model.edit([new ModelEdit('deleteRange', [left, right - left, [start, end]])], {
31-
selections: [new ModelEditSelection(left)],
24+
export function killRange(doc: EditableDocument, ranges: Array<[number, number]>) {
25+
const edits = [],
26+
// use tuple to track
27+
// [selection, original selection/cursor order before sorting by location, and amount of text deleted]
28+
selections: [ModelEditSelection, number, number][] = [];
29+
30+
/**
31+
* Sorting by location backwards simplifies the underlying "deleteRange" operation,
32+
* as the operation logic doesn't have to translate the range by the sum of each prior deletion.
33+
*
34+
* We still however have to do the aforementioned translation/relocation for the post-delete cursor replacement,
35+
* but that happens ONCE, at the end of this whole range killing series,
36+
* and also seems to not be subject to as many strange bugs as
37+
* when we do the translation/relocation for deletion operations.
38+
*
39+
* For example, it appears that sometimes a series of deletions DOESN'T take into account
40+
* prior (ascending order of location) deletions,
41+
* so in order to prevent incorrect text being deleted, we might want to precalculate the updated offsets
42+
* on behalf of the delete operations, as we suggested NOT doing above.
43+
*
44+
* However, sometimes, it DOES take into account the prior deletions (or at least some of them)
45+
*
46+
* Of course, if the latter occurs, yet we thought the former would, our preventative
47+
* precalculated offset adjustments would then cause
48+
* incorrect text to be deleted anyways.
49+
*/
50+
51+
_(ranges)
52+
.map((r, idx) => [r, idx] as const)
53+
.orderBy(([r]) => Math.min(...r), 'desc')
54+
.forEach(([range, idx]) => {
55+
// we assume the ranges passed above are some transformation of the current selections
56+
// therefore doc.selections[index] should be the range before the transformation... maybe
57+
const [left, right] = [Math.min(...range), Math.max(...range)];
58+
const length = right - left;
59+
edits.push(new ModelEdit('deleteRange', [left, length]));
60+
selections.push([new ModelEditSelection(left), idx, length]);
61+
});
62+
63+
return doc.model.edit(edits, {
64+
selections: _(selections)
65+
// return to original selection/cursor order
66+
.orderBy(([_, idx]) => idx)
67+
// pull each cursor backwards by the amount of text deleted by every prior (by location) cursor's delete operation
68+
.map(
69+
([selection], _index, others) =>
70+
new ModelEditSelection(
71+
selection.start -
72+
_(others)
73+
.filter(([s]) => s.start < selection.start)
74+
.reduce((sum, [_, __, length]) => sum + length, 0)
75+
)
76+
)
77+
.value(),
3278
});
3379
}
3480

@@ -683,38 +729,132 @@ export function spliceSexp(
683729
return doc.model.edit(edits, { undoStopBefore, selections });
684730
}
685731

732+
export function killSexpBackward(
733+
doc: EditableDocument,
734+
shouldKillAlsoCutToClipboard = () => false,
735+
copyRangeToClipboard = (doc: EditableDocument, range: Array<[number, number]>) => undefined
736+
) {
737+
const ranges = backwardSexpRange(
738+
doc,
739+
doc.selections.map((s) => s.active)
740+
);
741+
if (shouldKillAlsoCutToClipboard()) {
742+
copyRangeToClipboard(doc, ranges);
743+
}
744+
return killRange(doc, ranges);
745+
}
746+
747+
export function killSexpForward(
748+
doc: EditableDocument,
749+
shouldKillAlsoCutToClipboard = () => false,
750+
copyRangeToClipboard = (doc: EditableDocument, range: Array<[number, number]>) => undefined
751+
) {
752+
const ranges = forwardSexpRange(doc);
753+
if (shouldKillAlsoCutToClipboard()) {
754+
copyRangeToClipboard(doc, ranges);
755+
}
756+
return killRange(doc, ranges);
757+
}
758+
759+
/**
760+
* In making this compatible with multi-cursor,
761+
* we had to complicate the logic somewhat to make sure
762+
* deletions by prior cursors are taken into account by
763+
* later ones, and are relocated accordingly.
764+
*
765+
* See comments in paredit.killRange() for more details.
766+
*/
686767
export function killBackwardList(
687768
doc: EditableDocument,
688-
[start, end]: [number, number]
769+
ranges: Array<[number, number]> = doc.selections.map((s) => [s.start, s.end])
689770
): Thenable<ModelEditResult> {
690-
return doc.model.edit(
691-
[new ModelEdit('changeRange', [start, end, '', [end, end], [start, start]])],
692-
{
693-
selections: [new ModelEditSelection(start)],
694-
}
695-
);
771+
const edits: ModelEdit<'deleteRange'>[] = [],
772+
selections: [ModelEditSelection, number, number][] = [];
773+
774+
_(ranges)
775+
.map((r, idx) => [r, idx] as const)
776+
.orderBy(([r]) => Math.min(...r), 'desc')
777+
.forEach(([r, originalIndex]) => {
778+
const [left, right] = r;
779+
const cursor = doc.getTokenCursor(left);
780+
cursor.backwardList();
781+
// const offset = selections
782+
// .filter((s) => s.start < left)
783+
// .reduce((sum, s) => sum + s.distance, 0);
784+
// const start = cursor.offsetStart - offset;
785+
// const end = right - offset;
786+
// edits.push(new ModelEdit('deleteRange', [start, Math.abs(end - start)]));
787+
const start = cursor.offsetStart;
788+
const end = right;
789+
const length = Math.abs(end - start);
790+
edits.push(new ModelEdit('deleteRange', [start, length]));
791+
// selections.push([new ModelEditSelection(start, end), originalIndex, length]);
792+
selections.push([new ModelEditSelection(start, end), originalIndex, length]);
793+
});
794+
795+
return doc.model.edit(edits, {
796+
selections: _(selections)
797+
.orderBy(([_, originalIndex]) => originalIndex)
798+
.map(
799+
([selection], _idx, others) =>
800+
new ModelEditSelection(
801+
selection.start -
802+
_(others)
803+
.filter(([s]) => s.start < selection.start)
804+
.reduce((sum, [_, __, lengthDeleted]) => sum + lengthDeleted, 0)
805+
)
806+
)
807+
.value(),
808+
});
696809
}
697810

811+
/**
812+
* In making this compatible with multi-cursor,
813+
* we had to complicate the logic somewhat to make sure
814+
* deletions by prior cursors are taken into account by
815+
* later ones, and are relocated accordingly.
816+
*
817+
* See comments in paredit.killRange() for more details.
818+
*/
698819
export function killForwardList(
699820
doc: EditableDocument,
700-
[start, end]: [number, number]
821+
ranges: Array<[number, number]> = doc.selections.map((s) => [s.start, s.end])
701822
): Thenable<ModelEditResult> {
702-
const cursor = doc.getTokenCursor(start);
703-
const inComment =
704-
(cursor.getToken().type == 'comment' && start > cursor.offsetStart) ||
705-
cursor.getPrevToken().type == 'comment';
706-
return doc.model.edit(
707-
[
708-
new ModelEdit('changeRange', [
709-
start,
710-
end,
711-
inComment ? '\n' : '',
712-
[start, start],
713-
[start, start],
714-
]),
715-
],
716-
{ selections: [new ModelEditSelection(start)] }
717-
);
823+
const edits: ModelEdit<'changeRange'>[] = [],
824+
selections: [ModelEditSelection, number, number][] = [];
825+
826+
_(ranges)
827+
.map((r, idx) => [r, idx] as const)
828+
.orderBy(([r]) => Math.min(...r), 'desc')
829+
.forEach(([r, originalIndex]) => {
830+
const [left, right] = r;
831+
const cursor = doc.getTokenCursor(left);
832+
cursor.forwardList();
833+
const inComment =
834+
(cursor.getToken().type == 'comment' && left > cursor.offsetStart) ||
835+
cursor.getPrevToken().type == 'comment';
836+
837+
const start = cursor.offsetStart;
838+
const end = right;
839+
const length = Math.abs(end - start);
840+
edits.push(new ModelEdit('changeRange', [start, end, inComment ? '\n' : '']));
841+
selections.push([new ModelEditSelection(start, end), originalIndex, length]);
842+
});
843+
844+
return doc.model.edit(edits, {
845+
selections: _(selections)
846+
.orderBy(([_, originalIndex]) => originalIndex)
847+
.map(
848+
([selection], _idx, others) =>
849+
new ModelEditSelection(
850+
selection.start -
851+
_(others)
852+
.filter(([s]) => s.start < selection.start)
853+
.reduce((sum, [_, __, lengthDeleted]) => sum + lengthDeleted, 0)
854+
)
855+
)
856+
.value(),
857+
});
718858
}
719859

720860
// FIXME: check if this forEach solution works vs map into modelEdit batch
@@ -1272,40 +1412,64 @@ export function setSelectionStack(
12721412
doc.selectionsStack = selections;
12731413
}
12741414

1275-
export function raiseSexp(
1276-
doc: EditableDocument
1277-
// start = doc.selections.anchor,
1278-
// end = doc.selections.active
1279-
) {
1280-
const edits = [],
1281-
selections = clone(doc.selections);
1282-
doc.selections.forEach((selection, index) => {
1283-
const { start, end } = selection;
1415+
/**
1416+
* In making this compatible with multi-cursor,
1417+
* we had to complicate the logic somewhat to make sure
1418+
* deletions by prior cursors are taken into account by
1419+
* later ones, and are relocated accordingly.
1420+
*
1421+
* See comments in paredit.killRange() for more details.
1422+
*/
1423+
export function raiseSexp(doc: EditableDocument) {
1424+
const edits: ModelEdit<'changeRange'>[] = [],
1425+
selections = doc.selections.map((s) => [0, s.clone()] as [number, ModelEditSelection]);
1426+
1427+
_(doc.selections)
1428+
.map((s, index) => [s, index] as const)
1429+
.orderBy(([s]) => s.start, 'desc')
1430+
.forEach(([selection, originalIndex], index) => {
1431+
const { start, end } = selection;
12841432

1285-
const cursor = doc.getTokenCursor(end);
1286-
const [formStart, formEnd] = cursor.rangeForCurrentForm(start);
1287-
const isCaretTrailing = formEnd - start < start - formStart;
1288-
const startCursor = doc.getTokenCursor(formStart);
1289-
const endCursor = startCursor.clone();
1290-
if (endCursor.forwardSexp()) {
1291-
const raised = doc.model.getText(startCursor.offsetStart, endCursor.offsetStart);
1292-
startCursor.backwardList();
1293-
endCursor.forwardList();
1294-
if (startCursor.getPrevToken().type == 'open') {
1295-
startCursor.previous();
1296-
if (endCursor.getToken().type == 'close') {
1297-
edits.push(
1298-
new ModelEdit('changeRange', [startCursor.offsetStart, endCursor.offsetEnd, raised])
1299-
);
1300-
selections[index] = new ModelEditSelection(
1301-
isCaretTrailing ? startCursor.offsetStart + raised.length : startCursor.offsetStart
1302-
);
1433+
const cursor = doc.getTokenCursor(end);
1434+
const [formStart, formEnd] = cursor.rangeForCurrentForm(start);
1435+
const isCaretTrailing = formEnd - start < start - formStart;
1436+
const startCursor = doc.getTokenCursor(formStart);
1437+
const endCursor = startCursor.clone();
1438+
if (endCursor.forwardSexp()) {
1439+
const raised = doc.model.getText(startCursor.offsetStart, endCursor.offsetStart);
1440+
startCursor.backwardList();
1441+
endCursor.forwardList();
1442+
if (startCursor.getPrevToken().type == 'open') {
1443+
startCursor.previous();
1444+
if (endCursor.getToken().type == 'close') {
1445+
edits.push(
1446+
new ModelEdit('changeRange', [startCursor.offsetStart, endCursor.offsetEnd, raised])
1447+
);
1448+
const cursorPos = isCaretTrailing
1449+
? startCursor.offsetStart + raised.length
1450+
: startCursor.offsetStart;
1451+
1452+
selections[originalIndex] = [
1453+
endCursor.offsetEnd - startCursor.offsetStart - raised.length,
1454+
new ModelEditSelection(cursorPos),
1455+
];
1456+
}
13031457
}
13041458
}
1305-
}
1306-
});
1459+
});
13071460
return doc.model.edit(edits, {
1308-
selections,
1461+
selections: selections.map(([_, selection], __, others) => {
1462+
const s = selection.clone();
1463+
1464+
const offsetSum = others
1465+
.filter(([_, otherSel]) => otherSel.start < selection.start)
1466+
.reduce((sum, o) => sum + o[0], 0);
1467+
1468+
s.anchor -= offsetSum;
1469+
s.active -= offsetSum;
1470+
1471+
return s;
1472+
}),
13091473
});
13101474
}
13111475

src/extension-test/unit/cursor-doc/paredit-test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,6 +1136,18 @@ describe('paredit', () => {
11361136
void paredit.raiseSexp(a);
11371137
expect(textAndSelection(a)).toEqual(textAndSelection(b));
11381138
});
1139+
it('raises the current form when with two cursors ordered left->right', () => {
1140+
const a = docFromTextNotation('(a (b|)) (a (b|1)) (a (b))');
1141+
const b = docFromTextNotation('(a b|) (a b|1) (a (b))');
1142+
void paredit.raiseSexp(a);
1143+
expect(textAndSelection(a)).toEqual(textAndSelection(b));
1144+
});
1145+
it('raises the current form when with two cursors ordered right->left', () => {
1146+
const a = docFromTextNotation('(a (b|1)) (a (b|)) (a (b))');
1147+
const b = docFromTextNotation('(a b|1) (a b|) (a (b))'); // "(a b) (a b) (a (b))", [[ 10, 10], [4, 4]]
1148+
void paredit.raiseSexp(a);
1149+
expect(textAndSelection(a)).toEqual(textAndSelection(b));
1150+
});
11391151
});
11401152

11411153
describe('Kill character backwards (backspace)', () => {
@@ -1341,6 +1353,44 @@ describe('paredit', () => {
13411353
expect(textAndSelection(a)).toEqual(textAndSelection(b));
13421354
});
13431355
});
1356+
1357+
describe('Kill/Delete forward to End of List', () => {
1358+
it('Multi: kills last symbol in each list after cursor', async () => {
1359+
const a = docFromTextNotation('(|2a)(|1a)(|a)');
1360+
const b = docFromTextNotation('(|2)(|1)(|)');
1361+
await paredit.killForwardList(a);
1362+
expect(textAndSelection(a)).toEqual(textAndSelection(b));
1363+
});
1364+
});
1365+
1366+
describe('Kill/Delete backward to start of List', () => {
1367+
it('Multi: kills last symbol in list after cursor', async () => {
1368+
const a = docFromTextNotation('(a|)(a|1)(a|2)'); // "(a)(a)(a)" [[2,2], [5,5], [8,8]]
1369+
const b = docFromTextNotation('(|)(|1)(|2)'); // "()()()" [[1,1], [3,3], [5,5]],
1370+
await paredit.killBackwardList(a);
1371+
expect(textAndSelection(a)).toEqual(textAndSelection(b));
1372+
});
1373+
});
1374+
1375+
describe('Kill/Delete Sexp', () => {
1376+
describe('Kill/Delete Sexp Forward', () => {
1377+
it('Multi: kills/deletes sexp forwards', () => {
1378+
const a = docFromTextNotation('(|2a) (|1a) (|a) (a)');
1379+
const b = docFromTextNotation('(|2) (|1) (|) (a)');
1380+
void paredit.killSexpForward(a);
1381+
expect(textAndSelection(a)).toEqual(textAndSelection(b));
1382+
});
1383+
});
1384+
describe('Kill/Delete Sexp Backwards', () => {
1385+
it('Multi: kills/deletes sexp Backwards', async () => {
1386+
const a = docFromTextNotation('(a|2) (a|1) (a|) (a)');
1387+
const b = docFromTextNotation('(|2) (|1) (|) (a)');
1388+
await paredit.killSexpBackward(a);
1389+
expect(textAndSelection(a)).toEqual(textAndSelection(b));
1390+
});
1391+
});
1392+
});
1393+
13441394
describe('addRichComment', () => {
13451395
it('Adds Rich Comment after Top Level form', () => {
13461396
const a = docFromTextNotation('(fo|o)••(bar)');

0 commit comments

Comments
 (0)