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
17 changes: 17 additions & 0 deletions packages/core/src/renderables/EditBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

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

Expand All @@ -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)
}

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/renderables/Textarea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type TextareaAction =
| "delete-word-backward"
| "select-all"
| "submit"
| "yank"

export type KeyBinding = BaseKeyBinding<TextareaAction>
export type TextareaKeyAliasMap = Record<string, string>
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -255,6 +257,7 @@ export class TextareaRenderable extends EditBufferRenderable {
["delete-word-backward", () => this.deleteWordBackward()],
["select-all", () => this.selectAll()],
["submit", () => this.submit()],
["yank", () => this.yank()],
])
}

Expand Down
128 changes: 128 additions & 0 deletions packages/core/src/renderables/__tests__/Textarea.keybinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
5 changes: 5 additions & 0 deletions packages/keymap/src/addons/opentui/edit-buffer-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const editBufferActions = [
"delete-word-backward",
"select-all",
"submit",
"yank",
] as const satisfies readonly TextareaAction[]

export type EditBufferCommandName = (typeof editBufferActions)[number]
Expand Down Expand Up @@ -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<EditBufferCommandName, string>

const editBufferCommandDescriptions = {
Expand Down Expand Up @@ -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<EditBufferCommandName, string>

const editBufferFineGroups = {
Expand Down Expand Up @@ -192,6 +195,7 @@ const editBufferFineGroups = {
"delete-word-backward": "Delete",
"select-all": "Selection",
submit: "Submit",
yank: "Insert",
} as const satisfies Record<EditBufferCommandName, EditBufferFineGroup>

interface EditBufferMetadataOptions {
Expand Down Expand Up @@ -486,6 +490,7 @@ const editBufferCommandHandlers = {

return editor.submit()
},
yank: (editor: EditBufferRenderable) => editor.yank(),
} as const satisfies Record<EditBufferCommandName, (editor: EditBufferRenderable) => boolean>

function createEditBufferCommand(
Expand Down