Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
doc/feature
node_modules/
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,14 @@ Paste text ending in `\n` is treated as line-wise.
- While a mirror is in flight, `p` / `P` use the shadow so immediate yank/delete → put stays ordered.
- Pi owns the terminal clipboard backends; on Wayland external state may lag while the shadow stays authoritative for immediate puts.

## composability with other custom-editor extensions

Pi 0.71+ exposes [`ctx.ui.getEditorComponent()`](https://github.com/badlogic/pi-mono/issues/3935), which lets extensions wrap a previously installed custom editor instead of replacing it. pi-vim opts in: when another extension (for example, [`@jordyvd/pi-image-attachments`](https://www.npmjs.com/package/@jordyvd/pi-image-attachments)) has already installed a custom editor, pi-vim builds its `ModalEditor` as a subclass of that extension's class rather than the default `CustomEditor`.

Practically: load order in `settings.json` no longer determines which extension wins. Whichever extension runs `session_start` first becomes the inner editor, and pi-vim wraps it.

Extension authors composing on top of pi-vim should call `createModalEditor(Base)` (exported from this package) to get a `ModalEditor` subclass that extends `Base` instead of the default `CustomEditor`.

---

## known differences from full Vim
Expand Down
66 changes: 53 additions & 13 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ type ModalEditorInternals = {
setCursorCol?: (col: number) => void;
};

type CustomEditorConstructorArgs = ConstructorParameters<typeof CustomEditor>;
type ClipboardWriteFn = (text: string, signal: AbortSignal) => Promise<void>;
type ClipboardReadFn = () => string | null;
type ClipboardProcess = ReturnType<typeof spawn>;
Expand Down Expand Up @@ -528,7 +527,21 @@ class ClipboardMirror {
}
}

export class ModalEditor extends CustomEditor {
// TypeScript class-mixin pattern requires `any[]` for the constructor's rest args
// so super(...args) accepts whatever the base constructor expects.
// biome-ignore lint/suspicious/noExplicitAny: see comment above
type CustomEditorConstructor = new (...args: any[]) => CustomEditor;

/**
* Class factory that produces a ModalEditor subclass extending the provided
* base class. Pass `CustomEditor` for the standalone case, or another extension's
* editor class to compose vim modal editing on top of it.
*
* See https://github.com/badlogic/pi-mono/issues/3935 for the composability
* contract that this opt-in supports.
*/
export function createModalEditor<TBase extends CustomEditorConstructor>(Base: TBase) {
return class ModalEditor extends Base {
private mode: Mode = "insert";
private pendingMotion: PendingMotion = null;
private pendingTextObject: TextObjectKind | null = null;
Expand All @@ -548,7 +561,7 @@ export class ModalEditor extends CustomEditor {
private readonly redoStack: EditorSnapshot[] = [];
private currentTransition: TransitionState = "none";
private onChangeHooked: boolean = false;
private readonly labelColorizers: ModeLabelColorizers | null;
private labelColorizers: ModeLabelColorizers | null = null;
private readonly cursorShapeRuntime: CursorShapeRuntime | null;
private lastCursorShapeSequence: CursorShapeSequence | null = null;

Expand All @@ -560,15 +573,14 @@ export class ModalEditor extends CustomEditor {
private quitFn: () => void = () => {};
private notifyFn: (message: string) => void = () => {};

constructor(
tui: CustomEditorConstructorArgs[0],
theme: CustomEditorConstructorArgs[1],
kb: CustomEditorConstructorArgs[2],
labelColorizers?: ModeLabelColorizers | null,
) {
super(tui, theme, kb);
this.cursorShapeRuntime = getCursorShapeRuntime(tui);
this.labelColorizers = labelColorizers ?? null;
// biome-ignore lint/suspicious/noExplicitAny: rest-args passthrough for the mixin pattern.
constructor(...args: any[]) {
super(...args);
this.cursorShapeRuntime = getCursorShapeRuntime(args[0]);
}

setColorizers(colorizers: ModeLabelColorizers | null): void {
this.labelColorizers = colorizers ?? null;
}

// Test seams
Expand Down Expand Up @@ -3061,8 +3073,14 @@ export class ModalEditor extends CustomEditor {
if (count) return ` NORMAL ${count}_ `;
return " NORMAL ";
}
};
}

// Default class form, equivalent to the previous `class ModalEditor extends CustomEditor`.
// Re-exported for backwards compatibility with consumers that import `ModalEditor` directly.
export const ModalEditor = createModalEditor(CustomEditor);
export type ModalEditor = InstanceType<typeof ModalEditor>;

export default function (pi: ExtensionAPI) {
let cursorShapeCleanup: CursorShapeCleanup | null = null;

Expand All @@ -3080,9 +3098,31 @@ export default function (pi: ExtensionAPI) {
normal: (s: string) => t.fg("borderAccent", reverseVideo(s)),
ex: (s: string) => t.fg("warning", reverseVideo(s)),
} : null;
// Composability: if a previous extension installed a custom editor, build the modal
// editor as a subclass of that extension's class so its overrides remain in the chain.
// See https://github.com/badlogic/pi-mono/issues/3935
const previous = ctx.ui.getEditorComponent?.();

ctx.ui.setEditorComponent((tui, theme, kb) => {
cursorShapeCleanup = enableCursorShapeSupport(tui);
const editor = new ModalEditor(tui, theme, kb, colorizers);

let Base: CustomEditorConstructor = CustomEditor;
if (previous) {
// Probe the previous factory once to obtain its class. The probe instance is
// discarded; the actual editor is constructed below via `new Composed(...)`,
// which fires each constructor in the chain exactly once for the mounted instance.
const probe = previous(tui, theme, kb);
const probeCtor = probe?.constructor;
if (typeof probeCtor === "function") {
Base = probeCtor as CustomEditorConstructor;
}
}

// Preserve `instanceof ModalEditor` for the common standalone case by reusing
// the canonical class when no other extension is present in the chain.
const Composed = previous ? createModalEditor(Base) : ModalEditor;
const editor = new Composed(tui, theme, kb);
editor.setColorizers(colorizers);
editor.setClipboardMirrorPolicy(clipboardMirrorPolicy.policy);
editor.setQuitFn(() => ctx.shutdown());
editor.setNotifyFn((message) => ctx.ui.notify(message, "warning"));
Expand Down
Loading
Loading