Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions keybindings/move-edit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
139 changes: 139 additions & 0 deletions src/commands/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,145 @@ 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[] = [];

public run(textEditor: TextEditor, isInMarkMode: boolean, prefixArgument: number | undefined): Thenable<void> {
// 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;

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;
}
Comment on lines +117 to +124
Copy link

Copilot AI Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whitespace detection logic is duplicated in two while loops. Consider extracting this into a helper function like isWhitespace(char: string): boolean to improve maintainability and make the code more DRY.

Copilot uses AI. Check for mistakes.

let to = cursorChar;
const lineEnd = lineText.length;
while (to < lineEnd) {
const char = lineText[to];
if (char !== " " && char !== "\t") {
break;
}
to += 1;
}

// 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, " ");
} else {
// Second call: delete all spaces
editBuilder.delete(range);
}
} 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);
}
}
});
})
.then((success) => {
if (!success) {
logger.warn("cycleSpacing failed");
}
})
.then(() => {
// Update cursor positions based on the state
textEditor.selections = textEditor.selections.map((selection, index) => {
const line = selection.active.line;
const original = this.originalSpacing[index];

if (currentState === 0) {
// After replacing with one space, cursor should be after the space
if (original) {
const newPos = new vscode.Position(line, original.from + 1);
return new Selection(newPos, newPos);
}
} else if (currentState === 1) {
// After deleting all spaces, cursor at the position where spaces were
if (original) {
const newPos = new vscode.Position(line, original.from);
return new Selection(newPos, newPos);
}
} else {
// After restoring original spacing, place cursor at original position
if (original) {
const newPos = new vscode.Position(line, original.from + original.before.length);
return new Selection(newPos, newPos);
}
}
Copy link

Copilot AI Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback case returns the current selection unchanged, but it's unclear when this would be reached since all three states (0, 1, 2) are explicitly handled. Consider adding a comment explaining when this fallback occurs or refactoring to make the logic clearer.

Suggested change
}
}
// This fallback should never be reached because all three states (0, 1, 2) are explicitly handled above.
// If reached, it indicates an unexpected state; returning the current selection unchanged as a safeguard.

Copilot uses AI. Check for mistakes.
return new Selection(selection.active, selection.active);
});

// Advance to next state and save the state for interruption checking
this.cycleState = (this.cycleState + 1) % 3;
this.latestTextEditor = textEditor;
this.latestSelections = textEditor.selections;
});
}

public onDidInterruptTextEditor(): void {
// Check if the interruption was caused by this command's own changes
const interruptedBySelf =
this.latestTextEditor === vscode.window.activeTextEditor &&
this.latestSelections.every((latestSelection, i) => {
const activeSelection = vscode.window.activeTextEditor?.selections[i];
return activeSelection && latestSelection.isEqual(activeSelection);
});

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";

Expand Down
1 change: 1 addition & 0 deletions src/emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ export function activate(context: vscode.ExtensionContext): void {

bindEmulatorCommand("deleteHorizontalSpace");

bindEmulatorCommand("cycleSpacing");

registerEmulatorCommand("emacs-mcx.universalArgument", (emulator) => {
return emulator.universalArgument();
});
Expand Down
Loading
Loading