feat: more native keyboard navigation in multiline editor (#655)

Signed-off-by: Thibault Sottiaux <tibo@openai.com>
This commit is contained in:
Thibault Sottiaux
2025-04-25 10:35:30 -07:00
committed by GitHub
parent 69ce06d2f8
commit d401283a41

View File

@@ -419,6 +419,58 @@ export default class TextBuffer {
}); });
} }
/**
* Delete everything from the caret to the *end* of the current line. The
* caret itself stays in place (column remains unchanged). Mirrors the
* common Ctrl+K shortcut in many shells and editors.
*/
deleteToLineEnd(): void {
dbg("deleteToLineEnd", { beforeCursor: this.getCursor() });
const line = this.line(this.cursorRow);
if (this.cursorCol >= this.lineLen(this.cursorRow)) {
// Nothing to delete caret already at EOL.
return;
}
this.pushUndo();
// Keep the prefix before the caret, discard the remainder.
this.lines[this.cursorRow] = cpSlice(line, 0, this.cursorCol);
this.version++;
dbg("deleteToLineEnd:after", {
cursor: this.getCursor(),
line: this.line(this.cursorRow),
});
}
/**
* Delete everything from the *start* of the current line up to (but not
* including) the caret. The caret is moved to column-0, mirroring the
* behaviour of the familiar Ctrl+U binding.
*/
deleteToLineStart(): void {
dbg("deleteToLineStart", { beforeCursor: this.getCursor() });
if (this.cursorCol === 0) {
// Nothing to delete caret already at SOL.
return;
}
this.pushUndo();
const line = this.line(this.cursorRow);
this.lines[this.cursorRow] = cpSlice(line, this.cursorCol);
this.cursorCol = 0;
this.version++;
dbg("deleteToLineStart:after", {
cursor: this.getCursor(),
line: this.line(this.cursorRow),
});
}
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* Wordwise deletion helpers exposed publicly so tests (and future * Wordwise deletion helpers exposed publicly so tests (and future
* keybindings) can invoke them directly. * keybindings) can invoke them directly.
@@ -791,7 +843,6 @@ export default class TextBuffer {
!key["ctrl"] && !key["ctrl"] &&
!key["alt"] !key["alt"]
) { ) {
/* navigation */
this.move("left"); this.move("left");
} else if ( } else if (
key["rightArrow"] && key["rightArrow"] &&
@@ -816,7 +867,9 @@ export default class TextBuffer {
} else if (key["end"]) { } else if (key["end"]) {
this.move("end"); this.move("end");
} }
/* delete */
// Deletions
//
// In raw terminal mode many frameworks (Ink included) surface a physical // In raw terminal mode many frameworks (Ink included) surface a physical
// Backspace keypress as the single DEL (0x7f) byte placed in `input` with // Backspace keypress as the single DEL (0x7f) byte placed in `input` with
// no `key.backspace` flag set. Treat that byte exactly like an ordinary // no `key.backspace` flag set. Treat that byte exactly like an ordinary
@@ -839,22 +892,47 @@ export default class TextBuffer {
// forward deletion so we don't lose that capability on keyboards that // forward deletion so we don't lose that capability on keyboards that
// expose both behaviours. // expose both behaviours.
this.backspace(); this.backspace();
} } else if (key["delete"]) {
// Forward deletion (Fn+Delete on macOS, or Delete key with Shift held after // Forward deletion (Fn+Delete on macOS, or Delete key with Shift held after
// the branch above) remove the character *under / to the right* of the // the branch above) remove the character *under / to the right* of the
// caret, merging lines when at EOL similar to many editors. // caret, merging lines when at EOL similar to many editors.
else if (key["delete"]) {
this.del(); this.del();
} else if (input && !key["ctrl"] && !key["meta"]) { }
// Normal input
else if (input && !key["ctrl"] && !key["meta"]) {
this.insert(input); this.insert(input);
} }
/* printable */ // Emacs/readline-style shortcuts
else if (key["ctrl"] && (input === "a" || input === "\x01")) {
// Ctrl+A or ⌥← → start of line
this.move("home");
} else if (key["ctrl"] && (input === "e" || input === "\x05")) {
// Ctrl+E or ⌥→ → end of line
this.move("end");
} else if (key["ctrl"] && (input === "b" || input === "\x02")) {
// Ctrl+B → char left
this.move("left");
} else if (key["ctrl"] && (input === "f" || input === "\x06")) {
// Ctrl+F → char right
this.move("right");
} else if (key["ctrl"] && (input === "d" || input === "\x04")) {
// Ctrl+D → forward delete
this.del();
} else if (key["ctrl"] && (input === "k" || input === "\x0b")) {
// Ctrl+K → kill to EOL
this.deleteToLineEnd();
} else if (key["ctrl"] && (input === "u" || input === "\x15")) {
// Ctrl+U → kill to SOL
this.deleteToLineStart();
} else if (key["ctrl"] && (input === "w" || input === "\x17")) {
// Ctrl+W → delete word left
this.deleteWordLeft();
}
/* clamp + scroll */ /* printable, clamp + scroll */
this.ensureCursorInRange(); this.ensureCursorInRange();
this.ensureCursorVisible(vp); this.ensureCursorVisible(vp);
const cursorMoved = const cursorMoved =
this.cursorRow !== beforeRow || this.cursorCol !== beforeCol; this.cursorRow !== beforeRow || this.cursorCol !== beforeCol;