diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index f5f6bcc17..31b95a395 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -94,6 +94,9 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf private _scrollSpeed: number = 16 private _keyboardSelectionActive: boolean = false + // Latest text killed by Ctrl-K/Ctrl-U or word deletion, yanked with Ctrl+Y. + private _lastKilled: string | null = null + public readonly editBuffer: EditBuffer public readonly editorView: EditorView private nativeRenderable: NativeRenderableHandle | null = null @@ -843,6 +846,8 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf const eol = this.editBuffer.getEOL() if (eol.col > cursor.col) { + const killed = this.getTextRangeByCoords(cursor.row, cursor.col, eol.row, eol.col) + if (killed) this._lastKilled = killed this.editBuffer.deleteRange(cursor.row, cursor.col, eol.row, eol.col) } @@ -854,6 +859,8 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf const cursor = this.editorView.getCursor() if (cursor.col > 0) { + const killed = this.getTextRangeByCoords(cursor.row, 0, cursor.row, cursor.col) + if (killed) this._lastKilled = killed this.editBuffer.deleteRange(cursor.row, 0, cursor.row, cursor.col) } else if (cursor.row > 0) { this.editBuffer.deleteCharBackward() @@ -863,6 +870,12 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf return true } + public yank(): boolean { + if (this._lastKilled === null) return false + this.insertText(this._lastKilled) + return true + } + public undo(): boolean { this._ctx.clearSelection() this.editBuffer.undo() @@ -907,6 +920,8 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf const nextWord = this.editBuffer.getNextWordBoundary() if (nextWord.offset > currentCursor.offset) { + const killed = this.getTextRangeByCoords(currentCursor.row, currentCursor.col, nextWord.row, nextWord.col) + if (killed) this._lastKilled = killed this.editBuffer.deleteRange(currentCursor.row, currentCursor.col, nextWord.row, nextWord.col) } @@ -925,6 +940,8 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf const prevWord = this.editBuffer.getPrevWordBoundary() if (prevWord.offset < currentCursor.offset) { + const killed = this.getTextRangeByCoords(prevWord.row, prevWord.col, currentCursor.row, currentCursor.col) + if (killed) this._lastKilled = killed this.editBuffer.deleteRange(prevWord.row, prevWord.col, currentCursor.row, currentCursor.col) } diff --git a/packages/core/src/renderables/Textarea.ts b/packages/core/src/renderables/Textarea.ts index dded0c655..c5f9f281c 100644 --- a/packages/core/src/renderables/Textarea.ts +++ b/packages/core/src/renderables/Textarea.ts @@ -51,6 +51,7 @@ export type TextareaAction = | "delete-word-backward" | "select-all" | "submit" + | "yank" export type KeyBinding = BaseKeyBinding export type TextareaKeyAliasMap = Record @@ -86,6 +87,7 @@ export const defaultTextareaKeyBindings: KeyBinding[] = [ { name: "d", ctrl: true, shift: true, action: "delete-line" }, { name: "k", ctrl: true, action: "delete-to-line-end" }, { name: "u", ctrl: true, action: "delete-to-line-start" }, + { name: "y", ctrl: true, action: "yank" }, { name: "backspace", action: "backspace" }, { name: "backspace", shift: true, action: "backspace" }, { name: "d", ctrl: true, action: "delete" }, @@ -255,6 +257,7 @@ export class TextareaRenderable extends EditBufferRenderable { ["delete-word-backward", () => this.deleteWordBackward()], ["select-all", () => this.selectAll()], ["submit", () => this.submit()], + ["yank", () => this.yank()], ]) } diff --git a/packages/core/src/renderables/__tests__/Textarea.keybinding.test.ts b/packages/core/src/renderables/__tests__/Textarea.keybinding.test.ts index cfa0b4ba2..19a81b6a9 100644 --- a/packages/core/src/renderables/__tests__/Textarea.keybinding.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.keybinding.test.ts @@ -1108,6 +1108,134 @@ describe("Textarea - Keybinding Tests", () => { }) }) + describe("Kill Ring (Ctrl-Y yank after Ctrl-K/Ctrl-U)", () => { + it("should yank killed text back with ctrl+y after ctrl+k", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "Hello World", + width: 40, + height: 10, + }) + + editor.focus() + for (let i = 0; i < 6; i++) { + editor.moveCursorRight() + } + + currentMockInput.pressKey("k", { ctrl: true }) + expect(editor.plainText).toBe("Hello ") + + currentMockInput.pressKey("y", { ctrl: true }) + expect(editor.plainText).toBe("Hello World") + }) + + it("should yank a word deleted with alt+d after moving elsewhere", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "Hello World Test", + width: 40, + height: 10, + }) + + editor.focus() + + currentMockInput.pressKey("d", { meta: true }) + expect(editor.plainText).toBe("World Test") + expect(editor.logicalCursor.col).toBe(0) + + currentMockInput.pressKey("e", { ctrl: true }) + expect(editor.logicalCursor.col).toBe(10) + + currentMockInput.pressKey("y", { ctrl: true }) + expect(editor.plainText).toBe("World TestHello ") + expect(editor.logicalCursor.col).toBe(16) + }) + + it("should yank text killed from line start with ctrl+u", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "Hello World", + width: 40, + height: 10, + }) + + editor.focus() + for (let i = 0; i < 6; i++) { + editor.moveCursorRight() + } + + currentMockInput.pressKey("u", { ctrl: true }) + expect(editor.plainText).toBe("World") + + currentMockInput.pressKey("y", { ctrl: true }) + expect(editor.plainText).toBe("Hello World") + }) + + it("should yank a word deleted with ctrl+w after moving elsewhere", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "Hello World Test", + width: 40, + height: 10, + }) + + editor.focus() + editor.gotoLineEnd() + + currentMockInput.pressKey("w", { ctrl: true }) + expect(editor.plainText).toBe("Hello World ") + expect(editor.logicalCursor.col).toBe(12) + + currentMockInput.pressKey("a", { ctrl: true }) + expect(editor.logicalCursor.col).toBe(0) + + currentMockInput.pressKey("y", { ctrl: true }) + expect(editor.plainText).toBe("TestHello World ") + expect(editor.logicalCursor.col).toBe(4) + }) + + it("should do nothing with ctrl+y when kill ring is empty", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "Hello World", + width: 40, + height: 10, + }) + + editor.focus() + editor.gotoLineEnd() + + currentMockInput.pressKey("k", { ctrl: true }) + expect(editor.plainText).toBe("Hello World") + + currentMockInput.pressKey("y", { ctrl: true }) + expect(editor.plainText).toBe("Hello World") + }) + + it("should overwrite kill ring with the most recent ctrl+k", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "Hello World", + width: 40, + height: 10, + }) + + editor.focus() + + currentMockInput.pressKey("a", { ctrl: true }) + currentMockInput.pressKey("k", { ctrl: true }) + expect(editor.plainText).toBe("") + + editor.setText("Foo Bar") + editor.focus() + editor.gotoLineEnd() + for (let i = 0; i < 3; i++) { + editor.moveCursorLeft() + } + + currentMockInput.pressKey("k", { ctrl: true }) + expect(editor.plainText).toBe("Foo ") + + currentMockInput.pressKey("y", { ctrl: true }) + expect(editor.plainText).toBe("Foo Bar") + }) + + }) + describe("Wrapped Lines", () => { it("should delete to end of logical line with ctrl+k when wrapping enabled", async () => { const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { diff --git a/packages/keymap/src/addons/opentui/edit-buffer-bindings.ts b/packages/keymap/src/addons/opentui/edit-buffer-bindings.ts index 734ca9871..4a83e8f05 100644 --- a/packages/keymap/src/addons/opentui/edit-buffer-bindings.ts +++ b/packages/keymap/src/addons/opentui/edit-buffer-bindings.ts @@ -69,6 +69,7 @@ const editBufferActions = [ "delete-word-backward", "select-all", "submit", + "yank", ] as const satisfies readonly TextareaAction[] export type EditBufferCommandName = (typeof editBufferActions)[number] @@ -114,6 +115,7 @@ const editBufferCommandNames = { "delete-word-backward": "input.delete.word.backward", "select-all": "input.select.all", submit: "input.submit", + yank: "input.yank", } as const satisfies Record const editBufferCommandDescriptions = { @@ -153,6 +155,7 @@ const editBufferCommandDescriptions = { "delete-word-backward": "Delete previous word", "select-all": "Select all", submit: "Submit", + yank: "Yank killed text", } as const satisfies Record const editBufferFineGroups = { @@ -192,6 +195,7 @@ const editBufferFineGroups = { "delete-word-backward": "Delete", "select-all": "Selection", submit: "Submit", + yank: "Insert", } as const satisfies Record interface EditBufferMetadataOptions { @@ -486,6 +490,7 @@ const editBufferCommandHandlers = { return editor.submit() }, + yank: (editor: EditBufferRenderable) => editor.yank(), } as const satisfies Record boolean> function createEditBufferCommand(