Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
doc/feature
node_modules/
*.tgz
*.tsbuildinfo
*.log
.DS_Store
*.swp
*.swo
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 51 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand All @@ -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, {
Expand All @@ -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;
});
});
Expand Down
4 changes: 2 additions & 2 deletions script/pack-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions test/modal-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
88 changes: 88 additions & 0 deletions test/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it } from "node:test";
import {
readPiVimBooleanSetting,
readPiVimClipboardMirrorSetting,
readPiVimModeChange,
readPiVimModeColors,
} from "../settings.js";

Expand Down Expand Up @@ -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(
Expand Down