diff --git a/.gitignore b/.gitignore index 89ab3018..4f78d976 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ doc/feature +node_modules/ +*.tgz +*.tsbuildinfo +*.log +.DS_Store +*.swp +*.swo diff --git a/README.md b/README.md index 10e078a0..40230fc8 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,28 @@ Default-equivalent `settings.json`: } ``` -All keys are optional; omitting `piVim` is equivalent. Project overrides global; project `modeColors` replaces global `modeColors`, with missing modes defaulting above. +All keys are optional; omitting `piVim` is equivalent. The optional `modeChange` key (see below) is intentionally absent from the default — it has no useful default value. Project overrides global; project `modeColors` / `modeChange` replace their global counterparts whole, with missing sub-keys defaulting above. `clipboardMirror`: `all` mirrors unnamed writes; `yank` mirrors yanks; `never` keeps writes internal. Non-mirrored writes stay local for `p` / `P`. `syncBorderColorWithMode`: `false` keeps Pi thinking border; `true` follows mode colors. +`modeChange`: shell command to run on every transition into the named mode. Both keys are optional. The command runs detached via the system shell, stdio discarded, spawn errors silenced — editing never blocks or breaks. Hooks fire only on actual transitions: not on the initial mode, not on EX entry/exit (EX is a sub-state of normal), and not on no-op `Esc` from normal. Typical use is IME auto-switching via the third-party [`im-select`](https://github.com/daipeihust/im-select) CLI (cross-platform: macOS / Windows / Linux). Install per its README, then run `im-select` with no args to print your current IME id and plug those ids into the config: + +MacOS config maybe +```json +{ + "piVim": { + "modeChange": { + "insert": "im-select im.rime.inputmethod.Squirrel.Hans", + "normal": "im-select com.apple.keylayout.ABC" + } + } +} +``` + +pi-vim does not bundle `im-select` and does not care which tool you use — any shell command works. + ### mode colors `piVim.modeColors` accepts Pi theme foreground tokens. Missing, invalid, or unknown tokens use defaults above. diff --git a/index.ts b/index.ts index 3b18ebd3..a068e5f0 100644 --- a/index.ts +++ b/index.ts @@ -22,7 +22,11 @@ import { reverseCharMotion, type WordMotionClass, } from "./motions.js"; -import { type ModeColorSettings, readPiVimSettings } from "./settings.js"; +import { + type ModeChangeSettings, + type ModeColorSettings, + readPiVimSettings, +} from "./settings.js"; import { resolveDelimitedTextObjectRange, resolveMatchingPairMotionTarget, @@ -642,6 +646,7 @@ export class ModalEditor extends CustomEditor { private clipboardReadFn: ClipboardReadFn = readClipboardInChildProcess; private quitFn: () => void = () => {}; private notifyFn: (message: string) => void = () => {}; + private modeChangeFn: (mode: Mode, prevMode: Mode) => void = () => {}; constructor( tui: CustomEditorConstructorArgs[0], @@ -681,6 +686,9 @@ export class ModalEditor extends CustomEditor { setNotifyFn(fn: (message: string) => void): void { this.notifyFn = fn; } + setModeChangeFn(fn: (mode: Mode, prevMode: Mode) => void): void { + this.modeChangeFn = fn; + } getRegister(): string { return this.unnamedRegister; } @@ -718,7 +726,15 @@ export class ModalEditor extends CustomEditor { } private setMode(mode: Mode = "insert"): void { + const prev = this.mode; this.mode = mode; + if (prev !== mode) { + try { + this.modeChangeFn(mode, prev); + } catch { + // mode-change side effects must never break editing + } + } } override setText(text: string): void { @@ -2340,7 +2356,7 @@ export class ModalEditor extends CustomEditor { } this.deleteRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true); - if (op === "c") this.mode = "insert"; + if (op === "c") this.setMode("insert"); } private getDelimitedTextObjectCursorAbs(): number { @@ -3355,6 +3371,37 @@ export class ModalEditor extends CustomEditor { } } +function spawnModeChangeCommand(command: string): void { + if (!command) return; + try { + const child = spawn(command, { + shell: true, + stdio: "ignore", + detached: true, + windowsHide: true, + }); + child.on("error", () => { + // configured command may not exist on this machine; stay silent + }); + child.unref(); + } catch { + // spawn rejected synchronously (e.g., EMFILE) — never break the editor + } +} + +function createModeChangeHandler( + modeChange: ModeChangeSettings | undefined, +): ((mode: Mode, prevMode: Mode) => void) | null { + if (!modeChange) return null; + const insert = modeChange.insert; + const normal = modeChange.normal; + if (!insert && !normal) return null; + return (mode) => { + const command = mode === "insert" ? insert : normal; + if (command) spawnModeChangeCommand(command); + }; +} + export default function (pi: ExtensionAPI) { let cursorShapeCleanup: CursorShapeCleanup | null = null; @@ -3377,6 +3424,7 @@ export default function (pi: ExtensionAPI) { t && piVimSettings.syncBorderColorWithMode === true ? buildModeColorizers(t, modeColors) : null; + const modeChangeHandler = createModeChangeHandler(piVimSettings.modeChange); ctx.ui.setEditorComponent((tui, theme, kb) => { cursorShapeCleanup = enableCursorShapeSupport(tui); const editor = new ModalEditor(tui, theme, kb, { @@ -3386,6 +3434,7 @@ export default function (pi: ExtensionAPI) { editor.setClipboardMirrorPolicy(clipboardMirrorPolicy.policy); editor.setQuitFn(() => ctx.shutdown()); editor.setNotifyFn((message) => ctx.ui.notify(message, "warning")); + if (modeChangeHandler) editor.setModeChangeFn(modeChangeHandler); return editor; }); }); diff --git a/script/pack-check.ts b/script/pack-check.ts index aee0c134..9aa3426f 100644 --- a/script/pack-check.ts +++ b/script/pack-check.ts @@ -68,8 +68,8 @@ const THRESHOLDS = { // WORD/delimited text objects, mode-color settings, and matching-pair // motion add package surface. Keep budgets tight enough to catch // accidental docs/tests in the package. - maxSize: 32200, - maxUnpackedSize: 141500, + maxSize: 35000, + maxUnpackedSize: 150000, } as const; function compareStrings(a: string, b: string): number { diff --git a/settings.ts b/settings.ts index 2575f7b3..6384e4e7 100644 --- a/settings.ts +++ b/settings.ts @@ -6,14 +6,21 @@ export type ModeColorSettings = { ex?: string; }; +export type ModeChangeSettings = { + insert?: string; + normal?: string; +}; + export type PiVimSettings = { clipboardMirror?: unknown; modeColors?: ModeColorSettings; + modeChange?: ModeChangeSettings; syncBorderColorWithMode?: boolean; }; const M = Symbol(), C = ["insert", "normal", "ex"] as const, + MC = ["insert", "normal"] as const, T = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; const rec = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); @@ -36,6 +43,18 @@ function colors(v: unknown) { return Object.keys(r)[0] ? r : undefined; } +function modeChange(v: unknown): ModeChangeSettings | undefined { + if (!rec(v)) return; + const r: ModeChangeSettings = {}; + for (const k of MC) { + const x = v[k]; + if (typeof x !== "string") continue; + const t = x.trim(); + if (t.length > 0) r[k] = t; + } + return Object.keys(r)[0] ? r : undefined; +} + export function readPiVimClipboardMirrorSetting(g: unknown, p: unknown) { let v = get(p, "clipboardMirror"); if (v !== M) return v; @@ -53,6 +72,16 @@ export function readPiVimModeColors(g: unknown, p: unknown) { return colors(w); } +export function readPiVimModeChange(g: unknown, p: unknown) { + // Same whole-setting override semantics as modeColors: a project value + // (even if malformed) suppresses the global, so personal mode-change + // commands never leak into a shared project checkout. + const v = get(p, "modeChange"); + if (v !== M) return modeChange(v); + const w = get(g, "modeChange"); + return modeChange(w); +} + export function readPiVimBooleanSetting( g: unknown, p: unknown, @@ -71,6 +100,7 @@ function disk(cwd: string): PiVimSettings { return { clipboardMirror: readPiVimClipboardMirrorSetting(g, p), modeColors: readPiVimModeColors(g, p), + modeChange: readPiVimModeChange(g, p), syncBorderColorWithMode: readPiVimBooleanSetting( g, p, diff --git a/test/modal-editor.test.ts b/test/modal-editor.test.ts index d2bcb9ef..68916440 100644 --- a/test/modal-editor.test.ts +++ b/test/modal-editor.test.ts @@ -919,6 +919,64 @@ describe("mode transitions", () => { }); }); +describe("mode change callback", () => { + type ModeChangeEvent = { + mode: "normal" | "insert"; + prev: "normal" | "insert"; + }; + + it("fires on transitions only, with prev and new modes", () => { + const { editor } = createEditorWithSpy("hello"); + const events: ModeChangeEvent[] = []; + editor.setModeChangeFn((mode, prev) => events.push({ mode, prev })); + + // editor is in normal after createEditorWithSpy; setModeChangeFn was + // installed afterwards, so the prior insert→normal transition is not seen. + sendKeys(editor, ["i"]); + sendKeys(editor, ["\x1b"]); + sendKeys(editor, ["a"]); + sendKeys(editor, ["\x1b"]); + + assert.deepEqual(events, [ + { mode: "insert", prev: "normal" }, + { mode: "normal", prev: "insert" }, + { mode: "insert", prev: "normal" }, + { mode: "normal", prev: "insert" }, + ]); + }); + + it("does not fire on no-op same-mode setMode calls", () => { + const { editor } = createEditorWithSpy("hello"); + const events: ModeChangeEvent[] = []; + editor.setModeChangeFn((mode, prev) => events.push({ mode, prev })); + + // Already in normal mode; bare escape stays in normal and must not fire. + sendKeys(editor, ["\x1b"]); + + assert.deepEqual(events, []); + }); + + it("fires once for o / O which open a line and enter insert", () => { + const { editor } = createMultiLineEditor("foo\nbar"); + const events: ModeChangeEvent[] = []; + editor.setModeChangeFn((mode, prev) => events.push({ mode, prev })); + + sendKeys(editor, ["o"]); + assert.equal(editor.getMode(), "insert"); + assert.deepEqual(events, [{ mode: "insert", prev: "normal" }]); + }); + + it("swallows callback errors so editing keeps working", () => { + const { editor } = createEditorWithSpy("hello"); + editor.setModeChangeFn(() => { + throw new Error("boom"); + }); + + assert.doesNotThrow(() => sendKeys(editor, ["i"])); + assert.equal(editor.getMode(), "insert"); + }); +}); + describe("ex mini-mode", () => { it("renders the pending EX command and consumes prefixed counts", () => { const session = createEditorWithSpy("hello"); diff --git a/test/settings.test.ts b/test/settings.test.ts index 303df44d..83d04ee3 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -4,6 +4,7 @@ import { describe, it } from "node:test"; import { readPiVimBooleanSetting, readPiVimClipboardMirrorSetting, + readPiVimModeChange, readPiVimModeColors, } from "../settings.js"; @@ -211,6 +212,93 @@ describe("piVim boolean settings reader", () => { }); }); +describe("piVim modeChange settings reader", () => { + it("returns undefined when modeChange is missing", () => { + assert.equal(readPiVimModeChange(undefined, undefined), undefined); + assert.equal(readPiVimModeChange({ piVim: {} }, { piVim: {} }), undefined); + }); + + it("reads partial modeChange settings and trims values", () => { + assert.deepEqual( + readPiVimModeChange( + { piVim: { modeChange: { insert: " im-select Squirrel " } } }, + {}, + ), + { insert: "im-select Squirrel" }, + ); + }); + + it("reads both insert and normal commands", () => { + assert.deepEqual( + readPiVimModeChange( + { + piVim: { + modeChange: { + insert: "im-select im.rime.inputmethod.Squirrel.Hans", + normal: "im-select com.apple.keylayout.ABC", + }, + }, + }, + {}, + ), + { + insert: "im-select im.rime.inputmethod.Squirrel.Hans", + normal: "im-select com.apple.keylayout.ABC", + }, + ); + }); + + it("drops non-string and empty modeChange leaves", () => { + assert.deepEqual( + readPiVimModeChange( + { + piVim: { modeChange: { insert: 42, normal: " " } }, + }, + {}, + ), + undefined, + ); + assert.deepEqual( + readPiVimModeChange( + { piVim: { modeChange: { insert: "ok", normal: 42 } } }, + {}, + ), + { insert: "ok" }, + ); + }); + + it("lets project modeChange override global as a setting", () => { + assert.deepEqual( + readPiVimModeChange( + { + piVim: { + modeChange: { insert: "global-insert", normal: "global-normal" }, + }, + }, + { piVim: { modeChange: { normal: "project-normal" } } }, + ), + { normal: "project-normal" }, + ); + }); + + it("does not fall back to global when project modeChange is invalid", () => { + assert.equal( + readPiVimModeChange( + { piVim: { modeChange: { insert: "global-insert" } } }, + { piVim: { modeChange: null } }, + ), + undefined, + ); + assert.equal( + readPiVimModeChange( + { piVim: { modeChange: { insert: "global-insert" } } }, + { piVim: { modeChange: { insert: " " } } }, + ), + undefined, + ); + }); +}); + describe("piVim clipboard mirror settings reader", () => { it("returns undefined when global and project settings are missing", () => { assert.equal(