From 10a751c3286be50dc2fab82e496e45255c6c8377 Mon Sep 17 00:00:00 2001 From: Kamil Herbetko Date: Wed, 25 Mar 2026 18:53:03 +0100 Subject: [PATCH] Add configurable insert-mode escape chords. Add lauch configuration for testing and fix indentation in Misc type definition. --- .vscode/launch.json | 22 ++++++++++ README.md | 16 +++++-- package.json | 5 +++ presets/helix.js | 11 ++++- src/actions.ts | 105 +++++++++++++++++++++++++++++++++++++++++++- src/config.ts | 10 +++-- 6 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..74283e5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "extensionHost", + "request": "launch", + "name": "Launch Extension", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + ], + "outFiles": [ + "${workspaceFolder}/dist/**/*.js" + ], + "preLaunchTask": "npm: compile" + } + + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 430c2fa..107167c 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ To load it, import the preset using URI directly: ## How it works -This extension can only capture normal characters typed in modes except for insert mode. +This extension captures normal characters typed in all modes. +In insert mode, characters are still inserted by VS Code first, +then optional insert-mode keybindings (for example `jj` to leave insert mode) are matched. For special keys like `Esc`, `Ctrl` or `Alt`, they are handled by VS Code directly. So if you want to bind those keys to commands, you can directly map them in `keybindings.json`. @@ -197,8 +199,14 @@ There are 4 predefined modes (`normal`, `insert`, `select`, `command`) in this e but you are free to add more modes. Note that the mode name shouldn't start with underscore `_` as it is reserved for other config. -Keybindings can be defined for all modes except for insert mode, -because this extension will handle over to VS Code in insert mode. +Keybindings can be defined for all modes. + +For insert mode, keybindings are primarily useful for transition sequences +(for example `jj` / `kk` to switch to normal mode). +Characters are inserted first, and when a configured insert-mode sequence matches, +the matched characters are removed and the mapped command is executed. +By default, insert-mode sequence matching times out after `200ms` +(configurable via `modalEditor.misc.insertKeybindingTimeout`). Each key sequence can be prefixed with a number indicating the count. The count value will be stored in the `CommandContext`, @@ -228,7 +236,7 @@ If you need map a key sequence to a command, you can use a recursive keymap. Your config file should export a `Keybindings` object. There's a special mode `""` in `Keybindings` which means common keybindings. -It is shared by all the modes (except insert mode), +It is shared by all modes except command mode, and it can be overwritten by other modes. There's also a special key `""` in `Keymap` which represents a wildcard character diff --git a/package.json b/package.json index eb50073..6b9b59b 100644 --- a/package.json +++ b/package.json @@ -246,6 +246,11 @@ "type": "boolean", "description": "Parse leading number as prefix instead of normal keys", "default": true + }, + "modalEditor.misc.insertKeybindingTimeout": { + "type": "number", + "description": "Timeout in milliseconds for insert-mode keybinding sequences (<= 0 disables timeout)", + "default": 200 } } } diff --git a/presets/helix.js b/presets/helix.js index df9a020..09cb746 100644 --- a/presets/helix.js +++ b/presets/helix.js @@ -322,6 +322,16 @@ module.exports = { v: "modalEditor.setSelectMode" }, + // insert-mode escape chords + insert: { + j: { + j: "modalEditor.setNormalMode" + }, + k: { + k: "modalEditor.setNormalMode" + } + }, + select: { // cursor movement h: repeatable("cursorLeftSelect"), @@ -407,4 +417,3 @@ module.exports = { w: "workbench.action.files.save" } }; - diff --git a/src/actions.ts b/src/actions.ts index b70e50d..e1d388a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,10 +1,11 @@ import * as vscode from "vscode"; import { + isCommand, isCommandList, isSimpleCommand, isComplexCommand } from "./actions.guard"; -import { KeyEventHandler } from "./keybindings"; +import { KeyEventHandler, Keymap } from "./keybindings"; import { Config, getStyle, cursorStyleMap } from "./config"; import { KeyError } from "./error"; @@ -93,6 +94,12 @@ export class AppState { anchors: vscode.Position[]; /// selection before last command lastSelections: readonly vscode.Selection[] | undefined; + /// Current sub-keymap while parsing insert-mode key sequences + insertCurrentKeymap: Keymap | undefined; + /// Buffered insert-mode keys while parsing key sequences + insertKeys: string; + /// Timeout used for insert-mode key sequence matching + insertKeyTimer: NodeJS.Timeout | undefined; constructor( mode: string, @@ -104,9 +111,101 @@ export class AppState { this.registers = {}; this.records = {}; this.anchors = []; + this.insertCurrentKeymap = undefined; + this.insertKeys = ""; + this.insertKeyTimer = undefined; this.setMode(mode); } + getFromKeymap(keymap: Keymap | undefined, key: string) { + if (keymap) { + if (key in keymap) + return keymap[key]; + // wildcard + if ("" in keymap) + return keymap[""]; + } + return undefined; + } + + resetInsertKeys() { + if (this.insertKeyTimer) { + clearTimeout(this.insertKeyTimer); + this.insertKeyTimer = undefined; + } + this.insertCurrentKeymap = this.config.keybindings[INSERT]; + this.insertKeys = ""; + } + + setInsertKeyTimeout() { + if (this.insertKeyTimer) { + clearTimeout(this.insertKeyTimer); + } + + const timeout = this.config.misc.insertKeybindingTimeout; + if (timeout <= 0) { + return; + } + + this.insertKeyTimer = setTimeout(() => { + this.resetInsertKeys(); + }, timeout); + } + + async undoTypedText(length: number) { + for (let i = 0; i < length; ++i) { + await this.executeVSCommand("deleteLeft"); + } + } + + /** + * Parse insert-mode bindings. + * + * This allows sequences like "jj" to switch mode while still + * keeping normal typing behavior for non-matching sequences. + */ + async handleInsertKeybinding(key: string) { + const insertKeymap = this.config.keybindings[INSERT]; + if (!insertKeymap) { + return; + } + + let value = this.getFromKeymap(this.insertCurrentKeymap, key); + if (value) { + this.insertKeys += key; + if (isCommand(value)) { + const keys = this.insertKeys; + this.resetInsertKeys(); + await this.undoTypedText(keys.length); + await this.executeCommand(value, { keys }); + } + else { + this.insertCurrentKeymap = value; + this.setInsertKeyTimeout(); + } + return; + } + + this.resetInsertKeys(); + + // Re-try with the current key as a fresh sequence. + value = this.getFromKeymap(this.insertCurrentKeymap, key); + if (!value) { + return; + } + + this.insertKeys = key; + if (isCommand(value)) { + this.resetInsertKeys(); + await this.undoTypedText(1); + await this.executeCommand(value, { keys: key }); + } + else { + this.insertCurrentKeymap = value; + this.setInsertKeyTimeout(); + } + } + /// Update cursor and status bar updateStatus(editor?: vscode.TextEditor) { if (editor) { @@ -144,6 +243,7 @@ export class AppState { setMode(mode: string) { this.mode = mode; + this.resetInsertKeys(); this.updateStatus(vscode.window.activeTextEditor); if (mode === SELECT) { // record anchor @@ -181,9 +281,10 @@ export class AppState { } // call default handler for type - vscode.commands.executeCommand("default:type", { + await vscode.commands.executeCommand("default:type", { text: key }); + await this.handleInsertKeybinding(key); return; } diff --git a/src/config.ts b/src/config.ts index 83b5c4b..4745bc6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,8 +68,10 @@ export type Misc = { defaultMode: string, /// Clear selections when running command setInsertMode (deprecated) clearSelectionsOnInsertMode: boolean, - /// Parse leading number as prefix instead of normal keys - parseNumberPrefix: boolean, + /// Parse leading number as prefix instead of normal keys + parseNumberPrefix: boolean, + /// Timeout in milliseconds for insert-mode keybinding sequences + insertKeybindingTimeout: number, }; /** @@ -109,7 +111,8 @@ const defaultMisc: Misc = { autoloadPreset: "", defaultMode: NORMAL, clearSelectionsOnInsertMode: true, - parseNumberPrefix: true, + parseNumberPrefix: true, + insertKeybindingTimeout: 200, }; export function getStyle(mode: string, styles: Styles) { @@ -203,4 +206,3 @@ export async function readConfig() { misc } as Config; } -