Skip to content

Commit

Permalink
Fix the selection sync management and make mark-mode synced among edi…
Browse files Browse the repository at this point in the history
…tors on the same document (#1648)

* Fix the selection sync management and make mark-mode synced among editors on the same document

* Refactoring

* Fix
  • Loading branch information
whitphx authored Aug 27, 2023
1 parent 2f1e784 commit 9d1d9be
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 15 deletions.
81 changes: 66 additions & 15 deletions src/emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,34 +49,52 @@ export class EmacsEmulator implements IEmacsController, vscode.Disposable {
return this._isInMarkMode;
}

/**
* The anchor positions (=set mark positions) in mark mode are shared among TextEditors.
*/
private _markModeAnchors: vscode.Position[] = [];
private get markModeAnchors(): vscode.Position[] {
return this._markModeAnchors;
}

private rectMode = false;
public get inRectMarkMode(): boolean {
return this._isInMarkMode && this.rectMode;
}

/**
* It is usually synced to `textEditor.selections`.
* _nativeSelectionsMap[textEditor] is usually synced to `textEditor.selections`.
* Specially in rect-mark-mode, it is used to manage the underlying selections which the move commands directly manipulate
* and the `textEditor.selections` is in turn managed to visually represent rects reflecting the underlying `this._nativeSelections`.
*/
private _nativeSelections: readonly vscode.Selection[];
private _nativeSelectionsMap: WeakMap<TextEditor, readonly vscode.Selection[]> = new WeakMap();
public get nativeSelections(): readonly vscode.Selection[] {
return this._nativeSelections;
const maybeNativeSelections = this._nativeSelectionsMap.get(this.textEditor);
if (maybeNativeSelections) {
return maybeNativeSelections;
}

const nativeSelections = this.textEditor.selections;
this.setNativeSelections(nativeSelections);
return nativeSelections;
}
private setNativeSelections(nativeSelections: readonly vscode.Selection[]): void {
this._nativeSelectionsMap.set(this.textEditor, nativeSelections);
}
private applyNativeSelectionsAsRect(): void {
if (this.inRectMarkMode) {
const rectSelections = this._nativeSelections
.map(convertSelectionToRectSelections.bind(null, this.textEditor.document))
.reduce((a, b) => a.concat(b), []);
const rectSelections = this.nativeSelections.flatMap(
convertSelectionToRectSelections.bind(null, this.textEditor.document),
);
this.textEditor.selections = rectSelections;
}
}
public moveRectActives(navigateFn: (currentActive: vscode.Position, index: number) => vscode.Position): void {
const newNativeSelections = this._nativeSelections.map((s, i) => {
const newNativeSelections = this.nativeSelections.map((s, i) => {
const newActive = navigateFn(s.active, i);
return new vscode.Selection(s.anchor, newActive);
});
this._nativeSelections = newNativeSelections;
this.setNativeSelections(newNativeSelections);
this.applyNativeSelectionsAsRect();
}

Expand All @@ -91,7 +109,7 @@ export class EmacsEmulator implements IEmacsController, vscode.Disposable {
minibuffer: Minibuffer = new InputBoxMinibuffer(),
) {
this.textEditor = textEditor;
this._nativeSelections = this.rectMode ? [] : textEditor.selections; // TODO: `[]` is workaround.
this.setNativeSelections(this.rectMode ? [] : textEditor.selections); // TODO: `[]` is workaround.

this.markRing = new MarkRing(Configuration.instance.markRingMax);
this.prevExchangedMarks = null;
Expand Down Expand Up @@ -189,6 +207,40 @@ export class EmacsEmulator implements IEmacsController, vscode.Disposable {
this.killYanker.setTextEditor(textEditor);
}

/**
* This method is invoked from `onDidChangeActiveTextEditor`
* to restore and synchronize the mark mode and the anchor positions
* when the active text editor is switched.
*/
public switchTextEditor(textEditor: TextEditor): void {
this.setTextEditor(textEditor);

this.setNativeSelections(
this.isInMarkMode
? this.nativeSelections.map((selection, i) => {
const anchor = this.markModeAnchors[i] ?? selection.anchor;
return new vscode.Selection(anchor, selection.active);
})
: this.nativeSelections.map((selection) => {
return new vscode.Selection(selection.active, selection.active);
}),
);
if (this.rectMode) {
this.applyNativeSelectionsAsRect();
} else {
this.textEditor.selections = this.nativeSelections;
}

const active = this.nativeSelections[0]?.active ?? textEditor.selection.active;
textEditor.revealRange(new vscode.Range(active, active));

if (this.rectMode) {
// Pass
} else {
textEditor.options.cursorStyle = this.normalCursorStyle;
}
}

public getTextEditor(): TextEditor {
return this.textEditor;
}
Expand Down Expand Up @@ -221,13 +273,11 @@ export class EmacsEmulator implements IEmacsController, vscode.Disposable {
}

public onDidChangeTextEditorSelection(e: vscode.TextEditorSelectionChangeEvent): void {
const targetEditorId = e.textEditor.document.uri.toString();
const thisEditorId = this.textEditor.document.uri.toString();
if (targetEditorId === thisEditorId) {
if (e.textEditor === this.textEditor) {
this.onDidInterruptTextEditor();

if (!this.rectMode) {
this._nativeSelections = this.textEditor.selections;
this.setNativeSelections(this.textEditor.selections);
}
}
}
Expand Down Expand Up @@ -406,7 +456,7 @@ export class EmacsEmulator implements IEmacsController, vscode.Disposable {

this.rectMode = false;
vscode.commands.executeCommand("setContext", "emacs-mcx.inRectMarkMode", false);
this.textEditor.selections = this._nativeSelections;
this.textEditor.selections = this.nativeSelections;

if (!this.isInMarkMode) {
this.makeSelectionsEmpty();
Expand Down Expand Up @@ -443,6 +493,7 @@ export class EmacsEmulator implements IEmacsController, vscode.Disposable {

public enterMarkMode(pushMark = true): void {
this._isInMarkMode = true;
this._markModeAnchors = this.textEditor.selections.map((selection) => selection.anchor);
this.rectMode = false;

// At this moment, the only way to set the context for `when` conditions is `setContext` command.
Expand Down Expand Up @@ -513,7 +564,7 @@ export class EmacsEmulator implements IEmacsController, vscode.Disposable {
}

private makeSelectionsEmpty() {
const srcSelections = this.rectMode ? this._nativeSelections : this.textEditor.selections;
const srcSelections = this.rectMode ? this.nativeSelections : this.textEditor.selections;
this.textEditor.selections = srcSelections.map((selection) => new Selection(selection.active, selection.active));
}

Expand Down
15 changes: 15 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ export function activate(context: vscode.ExtensionContext): void {
return curEmulator;
}

context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor((editor) => {
if (editor == null) {
return;
}

const [curEmulator, isNew] = emulatorMap.getOrCreate(editor);
if (isNew) {
context.subscriptions.push(curEmulator);
}

curEmulator.switchTextEditor(editor);
}),
);

context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument(() => {
const documents = vscode.workspace.textDocuments;
Expand Down
122 changes: 122 additions & 0 deletions src/test/suite/editor-switch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import assert from "assert";
import * as vscode from "vscode";
import { EmacsEmulator } from "../../emulator";
import { cleanUpWorkspace, setupWorkspace, setEmptyCursors, assertSelectionsEqual } from "./utils";

suite("Emulator with multiple TextEditors on the same document", () => {
let firstTextEditor: vscode.TextEditor;
let secondTextEditor: vscode.TextEditor;
let emulator: EmacsEmulator;

setup(async () => {
const initialText = `0123456789
abcdefghij
ABCDEFGHIJ
klmnopqrst
KLMNOPQRST`;
firstTextEditor = await setupWorkspace(initialText);
emulator = new EmacsEmulator(firstTextEditor);

// Also, open another editor on the same document.
await vscode.window.showTextDocument(firstTextEditor.document, vscode.ViewColumn.Two);
secondTextEditor = vscode.window.activeTextEditor as vscode.TextEditor;

// Ensure that the activeTextEditor is still the first one.
await vscode.window.showTextDocument(firstTextEditor.document, vscode.ViewColumn.One);
assert.strictEqual(vscode.window.activeTextEditor, firstTextEditor);
});

teardown(cleanUpWorkspace);

test("the mark mode and the anchor positions are shared among the editors on the same document", async () => {
// Set the cursor position on the second editor to a different position from the first editor.
emulator.setTextEditor(secondTextEditor);
setEmptyCursors(secondTextEditor, [2, 2]);

// On the first editor, set the mark and move the cursor.
emulator.setTextEditor(firstTextEditor);
setEmptyCursors(firstTextEditor, [0, 0]);
emulator.setMarkCommand();
await emulator.runCommand("forwardChar");
await emulator.runCommand("nextLine");
assert.strictEqual(emulator.isInMarkMode, true);
assertSelectionsEqual(firstTextEditor, new vscode.Selection(0, 0, 1, 1));

// Focus on the second editor.
emulator.switchTextEditor(secondTextEditor); // This is called in onDidChangeActiveTextEditor event handler in extension.ts in the real case.

// The mark mode and the anchor position should be shared.
assert.strictEqual(emulator.isInMarkMode, true);
assertSelectionsEqual(secondTextEditor, new vscode.Selection(0, 0, 2, 2));

// Focus back to the first editor, and assert that the mark mode and the cursor positions have not been changed.
emulator.switchTextEditor(firstTextEditor); // This is called in onDidChangeActiveTextEditor event handler in extension.ts in the real case.
assert.strictEqual(emulator.isInMarkMode, true);
assertSelectionsEqual(firstTextEditor, new vscode.Selection(0, 0, 1, 1));

// Disable the mark mode on the first editor.
emulator.cancel();
assert.strictEqual(emulator.isInMarkMode, false);
assertSelectionsEqual(firstTextEditor, new vscode.Selection(1, 1, 1, 1));

// Focus on the second editor and assert that the mark mode has been disabled.
emulator.switchTextEditor(secondTextEditor); // This is called in onDidChangeActiveTextEditor event handler in extension.ts in the real case.
assert.strictEqual(emulator.isInMarkMode, false);
assertSelectionsEqual(secondTextEditor, new vscode.Selection(2, 2, 2, 2));
});

test("The rectangle mark mode and the anchor positions are shared among the editors on the same document", async () => {
// Set the cursor position on the second editor to a different position from the first editor.
emulator.setTextEditor(secondTextEditor);
setEmptyCursors(secondTextEditor, [3, 3]);

// On the first editor, start the rectangle mark mode and move the cursor.
emulator.setTextEditor(firstTextEditor);
setEmptyCursors(firstTextEditor, [0, 0]);
emulator.rectangleMarkMode();
await emulator.runCommand("forwardChar");
await emulator.runCommand("nextLine");
await emulator.runCommand("forwardChar");
await emulator.runCommand("nextLine");
assert.strictEqual(emulator.inRectMarkMode, true);
assertSelectionsEqual(
firstTextEditor,
new vscode.Selection(0, 0, 0, 2),
new vscode.Selection(1, 0, 1, 2),
new vscode.Selection(2, 0, 2, 2),
); // Selected region is a rectangle.

// Focus on the second editor.
emulator.switchTextEditor(secondTextEditor); // This is called in onDidChangeActiveTextEditor event handler in extension.ts in the real case.

// The mark mode and the anchor position should be shared.
assert.strictEqual(emulator.inRectMarkMode, true);
assertSelectionsEqual(
secondTextEditor,
new vscode.Selection(0, 0, 0, 3),
new vscode.Selection(1, 0, 1, 3),
new vscode.Selection(2, 0, 2, 3),
new vscode.Selection(3, 0, 3, 3),
); // Selected region is a rectangle.

// Focus back to the first editor, and assert that the mark mode and the cursor positions have not been changed.
emulator.switchTextEditor(firstTextEditor); // This is called in onDidChangeActiveTextEditor event handler in extension.ts in the real case.
assert.strictEqual(emulator.inRectMarkMode, true);
assertSelectionsEqual(
firstTextEditor,
new vscode.Selection(0, 0, 0, 2),
new vscode.Selection(1, 0, 1, 2),
new vscode.Selection(2, 0, 2, 2),
); // Selected region is a rectangle.

// Disable the mark mode on the first editor.
emulator.cancel();
assert.strictEqual(emulator.inRectMarkMode, false);
assertSelectionsEqual(firstTextEditor, new vscode.Selection(2, 2, 2, 2));

// Focus on the second editor and assert that the mark mode has been disabled.
emulator.switchTextEditor(secondTextEditor); // This is called in onDidChangeActiveTextEditor event handler in extension.ts in the real case.
assert.strictEqual(emulator.inRectMarkMode, false);
assertSelectionsEqual(secondTextEditor, new vscode.Selection(3, 3, 3, 3));
});
});

0 comments on commit 9d1d9be

Please sign in to comment.