diff --git a/keybindings/move-edit.json b/keybindings/move-edit.json index abcd8cbab7..9778d094b0 100644 --- a/keybindings/move-edit.json +++ b/keybindings/move-edit.json @@ -179,6 +179,11 @@ "command": "emacs-mcx.deleteHorizontalSpace", "when": "editorTextFocus && !editorReadonly" }, + { + "key": "meta+space", + "command": "emacs-mcx.cycleSpacing", + "when": "editorTextFocus && !editorReadonly" + }, { "key": "meta+d", "command": "emacs-mcx.killWord", diff --git a/package.json b/package.json index 822b083852..0c90823ed6 100644 --- a/package.json +++ b/package.json @@ -2501,6 +2501,26 @@ "command": "emacs-mcx.deleteHorizontalSpace", "when": "editorTextFocus && !editorReadonly && config.emacs-mcx.useMetaPrefixCtrlLeftBracket" }, + { + "key": "alt+space", + "command": "emacs-mcx.cycleSpacing", + "when": "editorTextFocus && !editorReadonly && config.emacs-mcx.useMetaPrefixAlt" + }, + { + "mac": "cmd+space", + "command": "emacs-mcx.cycleSpacing", + "when": "editorTextFocus && !editorReadonly && config.emacs-mcx.useMetaPrefixMacCmd" + }, + { + "key": "escape space", + "command": "emacs-mcx.cycleSpacing", + "when": "editorTextFocus && !editorReadonly && config.emacs-mcx.useMetaPrefixEscape" + }, + { + "key": "ctrl+[ space", + "command": "emacs-mcx.cycleSpacing", + "when": "editorTextFocus && !editorReadonly && config.emacs-mcx.useMetaPrefixCtrlLeftBracket" + }, { "key": "alt+d", "command": "emacs-mcx.killWord", diff --git a/src/commands/edit.ts b/src/commands/edit.ts index cd091e9809..bf75a8d09d 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -74,6 +74,161 @@ export class DeleteHorizontalSpace extends EmacsCommand { } } +export class CycleSpacing extends EmacsCommand { + public readonly id = "cycleSpacing"; + + private cycleState = 0; // 0: one space, 1: no space, 2: restore + private originalSpacing: Array<{ before: string; after: string; from: number }> = []; + private latestTextEditor: TextEditor | null = null; + private latestSelections: readonly vscode.Selection[] = []; + private isRunning = false; // Flag to track when we're actively executing + + public run(textEditor: TextEditor, isInMarkMode: boolean, prefixArgument: number | undefined): Thenable { + this.isRunning = true; + // Check if this is a continuation of the previous cycle-spacing call + const isContinuation = + this.latestTextEditor === textEditor && + this.latestSelections.length === textEditor.selections.length && + this.latestSelections.every((latestSelection, i) => { + const activeSelection = textEditor.selections[i]; + return activeSelection && latestSelection.isEqual(activeSelection); + }); + + if (!isContinuation) { + // Start a new cycle + this.cycleState = 0; + this.originalSpacing = []; + } + + const currentState = this.cycleState; + + // Calculate expected cursor positions before edit to prevent interruption during edit + const expectedCursorPositions: vscode.Position[] = []; + + return textEditor + .edit((editBuilder) => { + textEditor.selections.forEach((selection, index) => { + const line = selection.active.line; + const lineText = textEditor.document.lineAt(line).text; + const cursorChar = selection.active.character; + + if (currentState === 0 || currentState === 1) { + // Find the range of spaces/tabs around the cursor for states 0 and 1 + let from = cursorChar; + while (from > 0) { + const char = lineText[from - 1]; + if (char !== " " && char !== "\t") { + break; + } + from -= 1; + } + + let to = cursorChar; + const lineEnd = lineText.length; + while (to < lineEnd) { + const char = lineText[to]; + if (char !== " " && char !== "\t") { + break; + } + to += 1; + } + + // Check if there's any whitespace - if not, do nothing + if (from === to) { + expectedCursorPositions[index] = new vscode.Position(line, cursorChar); + } else { + // Store original spacing on first call of the cycle + if (currentState === 0) { + const beforeSpacing = lineText.substring(from, cursorChar); + const afterSpacing = lineText.substring(cursorChar, to); + this.originalSpacing[index] = { before: beforeSpacing, after: afterSpacing, from }; + } + + const range = new Range(line, from, line, to); + + if (currentState === 0) { + // First call: delete all but one space + editBuilder.replace(range, " "); + // Calculate expected cursor position + const original = this.originalSpacing[index]; + if (original) { + expectedCursorPositions[index] = new vscode.Position(line, original.from + 1); + } + } else { + // Second call: delete all spaces + editBuilder.delete(range); + // Calculate expected cursor position + const original = this.originalSpacing[index]; + if (original) { + expectedCursorPositions[index] = new vscode.Position(line, original.from); + } + } + } + } else { + // Third call (state 2): restore original spacing + const original = this.originalSpacing[index]; + if (original) { + // Always use insert at the original position + editBuilder.insert(new vscode.Position(line, original.from), original.before + original.after); + // Calculate expected cursor position - preserve original position within spacing + expectedCursorPositions[index] = new vscode.Position(line, original.from + original.before.length); + } + } + }); + + // Save state and editor before edit completes + // Note: We advance the state here (not in .then()) to prevent race conditions with interruption handler + this.cycleState = (currentState + 1) % 3; + this.latestTextEditor = textEditor; + }) + .then((success) => { + if (!success) { + logger.warn("cycleSpacing failed"); + } + }) + .then(() => { + // Update cursor positions based on calculated positions + textEditor.selections = textEditor.selections.map((selection, index) => { + const expectedPos = expectedCursorPositions[index]; + if (expectedPos) { + return new Selection(expectedPos, expectedPos); + } + return new Selection(selection.active, selection.active); + }); + + // Update saved selections to match actual selections + this.latestSelections = textEditor.selections; + this.isRunning = false; // Clear running flag + }); + } + + public onDidInterruptTextEditor(): void { + // Check if the interruption was caused by this command's own changes + // If we're actively running, don't reset (it's our own edit causing the interruption) + if (this.isRunning) { + return; // Ignore interruptions while we're running + } + + const sameEditor = this.latestTextEditor === vscode.window.activeTextEditor; + const selectionsMatch = + this.latestSelections.length > 0 && + this.latestSelections.every((latestSelection, i) => { + const activeSelection = vscode.window.activeTextEditor?.selections[i]; + return activeSelection && latestSelection.isEqual(activeSelection); + }); + + const interruptedBySelf = sameEditor && selectionsMatch; + + if (!interruptedBySelf) { + // Reset state only if interrupted by another command + this.cycleState = 0; + this.originalSpacing = []; + this.latestTextEditor = null; + this.latestSelections = []; + } + } +} + export class NewLine extends EmacsCommand { public readonly id = "newLine"; diff --git a/src/emulator.ts b/src/emulator.ts index b802f57612..da8614da6c 100644 --- a/src/emulator.ts +++ b/src/emulator.ts @@ -191,6 +191,7 @@ export class EmacsEmulator implements IEmacsController, vscode.Disposable { new EditCommands.DeleteBackwardChar(this), new EditCommands.DeleteForwardChar(this), new EditCommands.DeleteHorizontalSpace(this), + new EditCommands.CycleSpacing(this), new EditCommands.NewLine(this), new DeleteBlankLinesCommands.DeleteBlankLines(this), new TransposeCommands.TransposeChars(this), diff --git a/src/extension.ts b/src/extension.ts index 302beb998d..8c0ba6ed01 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -185,6 +185,8 @@ export function activate(context: vscode.ExtensionContext): void { bindEmulatorCommand("deleteHorizontalSpace"); + bindEmulatorCommand("cycleSpacing"); + registerEmulatorCommand("emacs-mcx.universalArgument", (emulator) => { return emulator.universalArgument(); }); diff --git a/src/test/suite/commands/edit.cycle-spacing.test.ts b/src/test/suite/commands/edit.cycle-spacing.test.ts new file mode 100644 index 0000000000..d699632890 --- /dev/null +++ b/src/test/suite/commands/edit.cycle-spacing.test.ts @@ -0,0 +1,283 @@ +import * as vscode from "vscode"; +import { EmacsEmulator } from "../../../emulator"; +import { + assertTextEqual, + cleanUpWorkspace, + setEmptyCursors, + assertCursorsEqual, + setupWorkspace, + createEmulator, + delay, +} from "../utils"; + +suite("cycle-spacing", () => { + let activeTextEditor: vscode.TextEditor; + let emulator: EmacsEmulator; + + suite("basic cycling behavior with spaces", () => { + setup(async () => { + const initialText = "foo bar"; + activeTextEditor = await setupWorkspace(initialText); + emulator = createEmulator(activeTextEditor); + }); + + teardown(cleanUpWorkspace); + + test("first call: replace all spaces with one space", async () => { + setEmptyCursors(activeTextEditor, [0, 5]); // cursor in middle of spaces + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + assertCursorsEqual(activeTextEditor, [0, 4]); // cursor after the single space + }); + + test("second call: delete all spaces", async () => { + setEmptyCursors(activeTextEditor, [0, 5]); // cursor in middle of spaces + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + assertCursorsEqual(activeTextEditor, [0, 3]); // cursor between foo and bar + }); + + test("third call: restore original spacing", async () => { + setEmptyCursors(activeTextEditor, [0, 5]); // cursor in middle of spaces + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + assertCursorsEqual(activeTextEditor, [0, 3]); // cursor at original position within spacing + }); + + test("fourth call: cycles back to one space", async () => { + setEmptyCursors(activeTextEditor, [0, 5]); // cursor in middle of spaces + await emulator.runCommand("cycleSpacing"); + await emulator.runCommand("cycleSpacing"); + await emulator.runCommand("cycleSpacing"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + assertCursorsEqual(activeTextEditor, [0, 4]); // cursor after the single space + }); + }); + + suite("cycling with cursor at different positions", () => { + setup(async () => { + const initialText = "foo bar"; + activeTextEditor = await setupWorkspace(initialText); + emulator = createEmulator(activeTextEditor); + }); + + teardown(cleanUpWorkspace); + + test("cursor before spaces", async () => { + setEmptyCursors(activeTextEditor, [0, 3]); // cursor right after "foo" + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + }); + + test("cursor after spaces", async () => { + setEmptyCursors(activeTextEditor, [0, 6]); // cursor right before "bar" + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + }); + }); + + suite("cycling with tabs", () => { + setup(async () => { + const initialText = "foo\t\t\tbar"; + activeTextEditor = await setupWorkspace(initialText); + emulator = createEmulator(activeTextEditor); + }); + + teardown(cleanUpWorkspace); + + test("replaces tabs with single space, then deletes, then restores", async () => { + setEmptyCursors(activeTextEditor, [0, 4]); // cursor in middle of tabs + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo\t\t\tbar"); + }); + }); + + suite("cycling with mixed spaces and tabs", () => { + setup(async () => { + const initialText = "foo \t \t bar"; + activeTextEditor = await setupWorkspace(initialText); + emulator = createEmulator(activeTextEditor); + }); + + teardown(cleanUpWorkspace); + + test("handles mixed whitespace", async () => { + setEmptyCursors(activeTextEditor, [0, 5]); // cursor in middle of whitespace + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo \t \t bar"); + }); + }); + + suite("multi-cursor support", () => { + setup(async () => { + const initialText = "foo bar\nbaz qux"; + activeTextEditor = await setupWorkspace(initialText); + emulator = createEmulator(activeTextEditor); + }); + + teardown(cleanUpWorkspace); + + test("cycles spacing at multiple cursors independently", async () => { + setEmptyCursors(activeTextEditor, [0, 5], [1, 4]); // cursors in both whitespace regions + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar\nbaz qux"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar\nbazqux"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar\nbaz qux"); + }); + }); + + suite("edge cases", () => { + setup(async () => { + const initialText = "foobar"; + activeTextEditor = await setupWorkspace(initialText); + emulator = createEmulator(activeTextEditor); + }); + + teardown(cleanUpWorkspace); + + test("no whitespace around cursor: no effect", async () => { + setEmptyCursors(activeTextEditor, [0, 3]); // cursor in middle of "foobar" + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + }); + }); + + suite("interruption handling", () => { + setup(async () => { + const initialText = "foo bar"; + activeTextEditor = await setupWorkspace(initialText); + emulator = createEmulator(activeTextEditor); + }); + + teardown(cleanUpWorkspace); + + test("interruption resets cycling state", async () => { + setEmptyCursors(activeTextEditor, [0, 5]); // cursor in middle of spaces + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); // one space + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); // no space + + // Interrupt by simulating user-cancel + emulator.onDidInterruptTextEditor({ reason: "user-cancel" }); + + // Next call should start from beginning (one space), not continue to restore + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); // one space (not restored) + }); + + test("cursor movement interrupts cycling", async () => { + setEmptyCursors(activeTextEditor, [0, 5]); // cursor in middle of spaces + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); // one space + + // Move cursor (this should interrupt) + setEmptyCursors(activeTextEditor, [0, 0]); + await delay(100); // Wait for interruption to be processed + + // Move cursor back and call again - should start from beginning + setEmptyCursors(activeTextEditor, [0, 4]); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); // no space (second state, not third) + }); + + test("document change interrupts cycling", async () => { + setEmptyCursors(activeTextEditor, [0, 5]); // cursor in middle of spaces + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); // one space + + // Simulate document change interruption + emulator.onDidInterruptTextEditor({ + reason: "document-changed", + originalEvent: { + reason: undefined, + contentChanges: [], + document: activeTextEditor.document, + }, + }); + + // Next call should start from beginning + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); // one space (restarted cycle) + }); + }); + + suite("with asymmetric spacing", () => { + setup(async () => { + const initialText = "foo bar"; + activeTextEditor = await setupWorkspace(initialText); + emulator = createEmulator(activeTextEditor); + }); + + teardown(cleanUpWorkspace); + + test("cursor left of center: preserves left spacing on restore", async () => { + setEmptyCursors(activeTextEditor, [0, 4]); // cursor after first space + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + assertCursorsEqual(activeTextEditor, [0, 4]); // cursor at position where one space was before + }); + + test("cursor right of center: preserves right spacing on restore", async () => { + setEmptyCursors(activeTextEditor, [0, 5]); // cursor after second space + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); + assertCursorsEqual(activeTextEditor, [0, 5]); // cursor at original position + }); + }); + + suite("single space case", () => { + setup(async () => { + const initialText = "foo bar"; + activeTextEditor = await setupWorkspace(initialText); + emulator = createEmulator(activeTextEditor); + }); + + teardown(cleanUpWorkspace); + + test("cycling on single space", async () => { + setEmptyCursors(activeTextEditor, [0, 4]); // cursor in the single space + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); // stays one space + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foobar"); // deletes the space + await emulator.runCommand("cycleSpacing"); + assertTextEqual(activeTextEditor, "foo bar"); // restores the single space + }); + }); +});