Skip to content

Commit 8d83117

Browse files
authored
Merge pull request #2474 from BetterThanTomorrow/wip/rayat/paredit/multicursor/rewrap
Paredit Multicursor - Rewrap
2 parents 9a6e1d2 + b1c3e1c commit 8d83117

File tree

7 files changed

+305
-31
lines changed

7 files changed

+305
-31
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Changes to Calva.
44

55
## [Unreleased]
66

7+
- [Implement experimental support for multicursor rewrap commands](https://github.com/BetterThanTomorrow/calva/issues/2448). Enable `calva.paredit.multicursor` in your settings to try it out. Closes [#2473](https://github.com/BetterThanTomorrow/calva/issues/2473)
8+
79
## [2.0.432] - 2024-03-26
810

911
- Fix: [Extraneous newlines printed to terminal for some output](https://github.com/BetterThanTomorrow/calva/issues/2468)

docs/site/paredit.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,5 @@ Happy Editing! ❤️
156156
There is an ongoing effort to support simultaneous multicursor editing with Paredit. This is an experimental feature and is not enabled by default. To enable it, set `calva.paredit.multicursor` to `true`. This feature is still in development and may not work as expected in all cases. Currently, this supports the following categories:
157157

158158
- Movement
159-
- Selection
159+
- Selection (except for `Select Current Form` - coming soon!)
160+
- Rewrap

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1023,7 +1023,7 @@
10231023
},
10241024
"calva.paredit.multicursor": {
10251025
"type": "boolean",
1026-
"markdownDescription": "Experimental: Support for multiple cursors in paredit commands.\nCurrently supported commands:\n- Cursor movement\n- Cursor selection",
1026+
"markdownDescription": "Experimental: Support for multiple cursors in paredit commands.\nCurrently supported commands:\n- Cursor movement\n- Cursor selection\n- Rewrap",
10271027
"default": false,
10281028
"scope": "window"
10291029
}

src/cursor-doc/paredit.ts

+92-21
Original file line numberDiff line numberDiff line change
@@ -670,32 +670,103 @@ export async function wrapSexpr(
670670
}
671671
}
672672

673-
export async function rewrapSexpr(
673+
/**
674+
* 'Rewraps' the lists containing each cursor/selection, as provided by `selections`, with
675+
* the provided `open` and `close` strings.
676+
*
677+
* Single cursor is just the simpler special case when `selections.length` is 1
678+
* High level overview:
679+
* - For each cursor, find the offsets/ranges for its containing list's open/close tokens.
680+
* - Make 2 ModelEdits for each token's replacement + 1 Selection; record the offset change.
681+
* - Dedupe each edit (as multi cursors could be in the same list).
682+
* - Then, reposition the edits and selections by the preceding edits' offset changes.
683+
* - Finally, apply the edits and update the selections.
684+
*
685+
* @param doc
686+
* @param open
687+
* @param close
688+
* @param selections
689+
* @returns
690+
*/
691+
export function rewrapSexpr(
674692
doc: EditableDocument,
675693
open: string,
676694
close: string,
677-
start: number = doc.selections[0].anchor,
678-
end: number = doc.selections[0].active
679-
): Promise<Thenable<boolean>> {
680-
const cursor = doc.getTokenCursor(end);
681-
if (cursor.backwardList()) {
682-
cursor.backwardUpList();
683-
const oldOpenStart = cursor.offsetStart;
684-
const oldOpenLength = cursor.getToken().raw.length;
685-
const oldOpenEnd = oldOpenStart + oldOpenLength;
686-
if (cursor.forwardSexp()) {
687-
const oldCloseStart = cursor.offsetStart - close.length;
688-
const oldCloseEnd = cursor.offsetStart;
689-
const d = open.length - oldOpenLength;
690-
return doc.model.edit(
691-
[
692-
new ModelEdit('changeRange', [oldCloseStart, oldCloseEnd, close]),
693-
new ModelEdit('changeRange', [oldOpenStart, oldOpenEnd, open]),
694-
],
695-
{ selections: [new ModelEditSelection(end + d)] }
696-
);
695+
selections = [doc.selections[0]]
696+
) {
697+
const edits: { type: 'open' | 'close'; change: number; edit: ModelEdit<'changeRange'> }[] = [],
698+
newSelections = _.clone(selections).map((s) => ({ selection: s, change: 0 }));
699+
700+
selections.forEach((sel, index) => {
701+
const { active } = sel;
702+
const cursor = doc.getTokenCursor(active);
703+
if (cursor.backwardList()) {
704+
cursor.backwardUpList();
705+
const oldOpenStart = cursor.offsetStart;
706+
const oldOpenLength = cursor.getToken().raw.length;
707+
const oldOpenEnd = oldOpenStart + oldOpenLength;
708+
if (cursor.forwardSexp()) {
709+
const oldCloseStart = cursor.offsetStart - close.length;
710+
const oldCloseEnd = cursor.offsetStart;
711+
const openChange = open.length - oldOpenLength;
712+
edits.push(
713+
{
714+
edit: new ModelEdit('changeRange', [oldCloseStart, oldCloseEnd, close]),
715+
change: 0,
716+
type: 'close',
717+
},
718+
{
719+
edit: new ModelEdit('changeRange', [oldOpenStart, oldOpenEnd, open]),
720+
change: openChange,
721+
type: 'open',
722+
}
723+
);
724+
newSelections[index] = {
725+
selection: new ModelEditSelection(active),
726+
change: openChange,
727+
};
728+
}
697729
}
730+
});
731+
732+
// Due to the nature of dealing with list boundaries, multiple cursors could be targeting
733+
// the same lists, which will result in attempting to delete the same ranges twice. So we dedupe.
734+
const uniqEdits = _.uniqWith(edits, _.isEqual);
735+
736+
// for both edits and new selections, get the offset by which to move each based on prior edits
737+
function getOffset(cursorOffset: number) {
738+
return _(uniqEdits)
739+
.filter((x) => {
740+
const [xStart] = x.edit.args;
741+
return xStart < cursorOffset;
742+
})
743+
.map(({ change }) => change)
744+
.sum();
698745
}
746+
747+
const editsToApply = _(uniqEdits)
748+
// First, importantly, sort by list open char offset
749+
.sortBy((e) => e.edit.args[0])
750+
// now, let's iterate thru each cursor and adjust their positions if earlier chars are delete/added
751+
.map((e) => {
752+
const [oldStart, oldEnd, text] = e.edit.args;
753+
const offset = getOffset(oldStart);
754+
const newStart = oldStart + offset;
755+
const newEnd = oldEnd + offset;
756+
return { ...e.edit, args: [newStart, newEnd, text] as const };
757+
})
758+
.value();
759+
const selectionsToApply = newSelections.map(({ selection }) => {
760+
const { active } = selection;
761+
const newSel = selection.clone();
762+
const offset = getOffset(active);
763+
newSel.reposition(offset);
764+
return newSel;
765+
});
766+
767+
return doc.model.edit(editsToApply, {
768+
selections: selectionsToApply,
769+
});
699770
}
700771

701772
export async function splitSexp(doc: EditableDocument, start: number = doc.selections[0].active) {

src/extension-test/unit/paredit/commands-test.ts

+176-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as expect from 'expect';
22
import * as model from '../../../cursor-doc/model';
33
import * as handlers from '../../../paredit/commands';
4-
import { docFromTextNotation } from '../common/text-notation';
4+
import { docFromTextNotation, textNotationFromDoc } from '../common/text-notation';
55
import _ = require('lodash');
66

77
model.initScanner(20000);
@@ -1009,15 +1009,13 @@ describe('paredit commands', () => {
10091009
it('Single-cursor: Deals with empty lines', async () => {
10101010
const a = docFromTextNotation('\n|');
10111011
const b = docFromTextNotation('|');
1012-
// const expected = { range: textAndSelection(b)[1], editOptions: { skipFormat: false } };
10131012
await handlers.killLeft(a, false);
10141013
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
10151014
});
10161015

10171016
it('Single-cursor: Deals with empty lines (Windows)', async () => {
10181017
const a = docFromTextNotation('\r\n|');
10191018
const b = docFromTextNotation('|');
1020-
// const expected = { range: textAndSelection(b)[1], editOptions: { skipFormat: false } };
10211019
await handlers.killLeft(a, false);
10221020
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
10231021
});
@@ -1087,4 +1085,179 @@ describe('paredit commands', () => {
10871085
});
10881086
});
10891087
});
1088+
1089+
describe('editing', () => {
1090+
describe('wrapping', () => {
1091+
describe('rewrap', () => {
1092+
it('Single-cursor: Rewraps () -> []', async () => {
1093+
const a = docFromTextNotation('a (b c|) d');
1094+
const b = docFromTextNotation('a [b c|] d');
1095+
await handlers.rewrapSquare(a, false);
1096+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1097+
});
1098+
it('Multi-cursor: Rewraps () -> []', async () => {
1099+
const a = docFromTextNotation('(a|2 (b c|) |1d)|3');
1100+
const b = docFromTextNotation('[a|2 [b c|] |1d]|3');
1101+
await handlers.rewrapSquare(a, true);
1102+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1103+
});
1104+
1105+
it('Single-cursor: Rewraps [] -> ()', async () => {
1106+
const a = docFromTextNotation('[a [b c|] d]');
1107+
const b = docFromTextNotation('[a (b c|) d]');
1108+
await handlers.rewrapParens(a, false);
1109+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1110+
});
1111+
it('Multi-cursor: Rewraps [] -> ()', async () => {
1112+
const a = docFromTextNotation('[a|2 [b c|] |1d]|3');
1113+
const b = docFromTextNotation('(a|2 (b c|) |1d)|3');
1114+
await handlers.rewrapParens(a, true);
1115+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1116+
});
1117+
1118+
it('Single-cursor: Rewraps [] -> {}', async () => {
1119+
const a = docFromTextNotation('[a [b c|] d]');
1120+
const b = docFromTextNotation('[a {b c|} d]');
1121+
await handlers.rewrapCurly(a, false);
1122+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1123+
});
1124+
it('Multi-cursor: Rewraps [] -> {}', async () => {
1125+
const a = docFromTextNotation('[a|2 [b c|] |1d]|3');
1126+
const b = docFromTextNotation('{a|2 {b c|} |1d}|3');
1127+
await handlers.rewrapCurly(a, true);
1128+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1129+
});
1130+
1131+
it('Multi-cursor: Handles rewrapping nested forms [] -> {}', async () => {
1132+
const a = docFromTextNotation('[:d :e [a|1 [b c|]]]');
1133+
const b = docFromTextNotation('[:d :e {a|1 {b c|}}]');
1134+
await handlers.rewrapCurly(a, true);
1135+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1136+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1137+
});
1138+
it('Multi-cursor: Handles rewrapping nested forms [] -> {} 2', async () => {
1139+
const a = docFromTextNotation('[|1:d :e [a|2 [b c|]]]');
1140+
const b = docFromTextNotation('{|1:d :e {a|2 {b c|}}}');
1141+
await handlers.rewrapCurly(a, true);
1142+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1143+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1144+
});
1145+
it('Multi-cursor: Handles rewrapping nested forms mixed -> {}', async () => {
1146+
const a = docFromTextNotation('[:d :e (a|1 {b c|})]');
1147+
const b = docFromTextNotation('[:d :e {a|1 {b c|}}]');
1148+
await handlers.rewrapCurly(a, true);
1149+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1150+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1151+
});
1152+
it('Multi-cursor: Handles rewrapping nested forms mixed -> {} 2', async () => {
1153+
const a = docFromTextNotation('[|1:d :e (a|2 {b c|})]');
1154+
const b = docFromTextNotation('{|1:d :e {a|2 {b c|}}}');
1155+
await handlers.rewrapCurly(a, true);
1156+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1157+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1158+
});
1159+
1160+
it('Single-cursor: Rewraps #{} -> {}', async () => {
1161+
const a = docFromTextNotation('#{a #{b c|} d}');
1162+
const b = docFromTextNotation('#{a {b c|} d}');
1163+
await handlers.rewrapCurly(a, false);
1164+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1165+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1166+
});
1167+
it('Multi-cursor: Rewraps #{} -> {}', async () => {
1168+
const a = docFromTextNotation('#{a|2 #{b c|} |1d}|3');
1169+
const b = docFromTextNotation('{a|2 {b c|} |1d}|3');
1170+
await handlers.rewrapCurly(a, true);
1171+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1172+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1173+
});
1174+
1175+
it('Single-cursor: Rewraps #{} -> ""', async () => {
1176+
const a = docFromTextNotation('#{a #{b c|} d}');
1177+
const b = docFromTextNotation('#{a "b c|" d}');
1178+
await handlers.rewrapQuote(a, false);
1179+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1180+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1181+
});
1182+
it('Multi-cursor: Rewraps #{} -> ""', async () => {
1183+
const a = docFromTextNotation('#{a|2 #{b c|} |1d}|3');
1184+
const b = docFromTextNotation('"a|2 "b c|" |1d"|3');
1185+
await handlers.rewrapQuote(a, true);
1186+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1187+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1188+
});
1189+
it('Multi-cursor: Rewraps #{} -> "" 2', async () => {
1190+
const a = docFromTextNotation('#{a|2 #{b c|} |1d}|3\n#{a|6 #{b c|4} |5d}|7');
1191+
const b = docFromTextNotation('"a|2 "b c|" |1d"|3\n"a|6 "b c|4" |5d"|7');
1192+
await handlers.rewrapQuote(a, true);
1193+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1194+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1195+
});
1196+
it('Multi-cursor: Rewraps #{} -> [] 3', async () => {
1197+
const a = docFromTextNotation('#{a|2 #{b c|} |1d\n#{a|6 #{b c|4} |5d}}|3');
1198+
const b = docFromTextNotation('[a|2 [b c|] |1d\n[a|6 [b c|4] |5d]]|3');
1199+
await handlers.rewrapSquare(a, true);
1200+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1201+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1202+
});
1203+
1204+
it('Single-cursor: Rewraps [] -> #{}', async () => {
1205+
const a = docFromTextNotation('[[b c|] d]');
1206+
const b = docFromTextNotation('[#{b c|} d]');
1207+
await handlers.rewrapSet(a, false);
1208+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1209+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1210+
});
1211+
it('Multi-cursor: Rewraps [] -> #{}', async () => {
1212+
const a = docFromTextNotation('[[b|2 c|] |1d]|3');
1213+
const b = docFromTextNotation('#{#{b|2 c|} |1d}|3');
1214+
await handlers.rewrapSet(a, true);
1215+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1216+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1217+
});
1218+
it('Multi-cursor: Rewraps [] -> #{} 2', async () => {
1219+
const a = docFromTextNotation('[[b|2 c|] |1d]|3\n[a|6 [b c|4] |5d]|7');
1220+
const b = docFromTextNotation('#{#{b|2 c|} |1d}|3\n#{a|6 #{b c|4} |5d}|7');
1221+
await handlers.rewrapSet(a, true);
1222+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1223+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1224+
});
1225+
it('Multi-cursor: Rewraps [] -> #{} 3', async () => {
1226+
const a = docFromTextNotation('[[b|2 c|] |1d\n[a|6 [b c|4] |5d]]|3');
1227+
const b = docFromTextNotation('#{#{b|2 c|} |1d\n#{a|6 #{b c|4} |5d}}|3');
1228+
await handlers.rewrapSet(a, true);
1229+
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
1230+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1231+
});
1232+
1233+
// TODO: This tests current behavior. What should happen?
1234+
it('Single-cursor: Rewraps ^{} -> #{}', async () => {
1235+
const a = docFromTextNotation('^{^{b c|} d}');
1236+
const b = docFromTextNotation('^{#{b c|} d}');
1237+
await handlers.rewrapSet(a, false);
1238+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1239+
});
1240+
it('Multi-cursor: Rewraps ^{} -> #{}', async () => {
1241+
const a = docFromTextNotation('^{^{b|2 c|} |1d}|3');
1242+
const b = docFromTextNotation('#{#{b|2 c|} |1d}|3');
1243+
await handlers.rewrapSet(a, true);
1244+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1245+
});
1246+
1247+
// TODO: This tests current behavior. What should happen?
1248+
it('Single-cursor: Rewraps ~{} -> #{}', async () => {
1249+
const a = docFromTextNotation('~{~{b c|} d}');
1250+
const b = docFromTextNotation('~{#{b c|} d}');
1251+
await handlers.rewrapSet(a, false);
1252+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1253+
});
1254+
it('Multi-cursor: Rewraps ~{} -> #{}', async () => {
1255+
const a = docFromTextNotation('~{~{b|2 c|} |1d}|3');
1256+
const b = docFromTextNotation('#{#{b|2 c|} |1d}|3');
1257+
await handlers.rewrapSet(a, true);
1258+
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
1259+
});
1260+
});
1261+
});
1262+
});
10901263
});

src/paredit/commands.ts

+22
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,25 @@ export async function killLeft(
122122
result.editOptions
123123
);
124124
}
125+
126+
// REWRAP
127+
128+
export function rewrapQuote(doc: EditableDocument, isMulti: boolean) {
129+
return paredit.rewrapSexpr(doc, '"', '"', isMulti ? doc.selections : [doc.selections[0]]);
130+
}
131+
132+
export function rewrapSet(doc: EditableDocument, isMulti: boolean) {
133+
return paredit.rewrapSexpr(doc, '#{', '}', isMulti ? doc.selections : [doc.selections[0]]);
134+
}
135+
136+
export function rewrapCurly(doc: EditableDocument, isMulti: boolean) {
137+
return paredit.rewrapSexpr(doc, '{', '}', isMulti ? doc.selections : [doc.selections[0]]);
138+
}
139+
140+
export function rewrapSquare(doc: EditableDocument, isMulti: boolean) {
141+
return paredit.rewrapSexpr(doc, '[', ']', isMulti ? doc.selections : [doc.selections[0]]);
142+
}
143+
144+
export function rewrapParens(doc: EditableDocument, isMulti: boolean) {
145+
return paredit.rewrapSexpr(doc, '(', ')', isMulti ? doc.selections : [doc.selections[0]]);
146+
}

0 commit comments

Comments
 (0)