diff --git a/src/edit-keys.ts b/src/edit-keys.ts new file mode 100644 index 0000000..3c1216e --- /dev/null +++ b/src/edit-keys.ts @@ -0,0 +1,70 @@ +import type readline from "node:readline"; + +export type EditAction = + | "word-left" + | "word-right" + | "line-start" + | "line-end" + | "delete-word" + | "delete-line" + | null; + +const isWordChar = (char: string | undefined): boolean => + char !== undefined && char !== "\n" && !/\s/.test(char); + +/** Index of the start of the word before `cursor` (readline backward-word). */ +export function wordStart(chars: readonly string[], cursor: number): number { + let index = Math.min(cursor, chars.length); + while (index > 0 && !isWordChar(chars[index - 1]) && chars[index - 1] !== "\n") index -= 1; + if (index > 0 && chars[index - 1] === "\n" && index === cursor) return index - 1; + while (index > 0 && isWordChar(chars[index - 1])) index -= 1; + return index; +} + +/** Index of the end of the word after `cursor` (readline forward-word). */ +export function wordEnd(chars: readonly string[], cursor: number): number { + let index = Math.max(0, cursor); + while (index < chars.length && !isWordChar(chars[index]) && chars[index] !== "\n") index += 1; + if (index < chars.length && chars[index] === "\n" && index === cursor) return index + 1; + while (index < chars.length && isWordChar(chars[index])) index += 1; + return index; +} + +export function lineStart(chars: readonly string[], cursor: number): number { + let index = cursor; + while (index > 0 && chars[index - 1] !== "\n") index -= 1; + return index; +} + +export function lineEnd(chars: readonly string[], cursor: number): number { + let index = cursor; + while (index < chars.length && chars[index] !== "\n") index += 1; + return index; +} + +/** + * Map a readline keypress to an editing action beyond single-character moves. + * + * Terminals encode Option/Alt as xterm modifier 3 and Command/Super as 9; Node's + * readline folds both into `key.meta`, so the raw sequence tells them apart. + * Ghostty (and most macOS terminals) rewrite Cmd+Backspace to Ctrl+U and + * Opt+Backspace to Ctrl+W, matching the readline line/word kill bindings. + */ +export function resolveEditKey(key: readline.Key): EditAction { + const sequence = key.sequence ?? ""; + const superModifier = /;9[A-Z~]$/.test(sequence); + + if (key.ctrl && key.name === "u") return "delete-line"; + if (key.ctrl && key.name === "w") return "delete-word"; + if (key.meta && key.name === "backspace") return "delete-word"; + if (key.ctrl && key.name === "a") return "line-start"; + if (key.ctrl && key.name === "e") return "line-end"; + + if (key.meta && key.name === "b") return "word-left"; + if (key.meta && key.name === "f") return "word-right"; + + if (key.name === "left" && (key.meta || key.ctrl)) return superModifier ? "line-start" : "word-left"; + if (key.name === "right" && (key.meta || key.ctrl)) return superModifier ? "line-end" : "word-right"; + + return null; +} diff --git a/src/editor.ts b/src/editor.ts index 1a45d01..d05a744 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -5,6 +5,7 @@ import readline from "node:readline"; import { sanitizeTerminalText, wrapText } from "./format"; import { stateDir } from "./paths"; import { layoutComment } from "./layout"; +import { lineEnd, lineStart, resolveEditKey, wordEnd, wordStart } from "./edit-keys"; import { charWidth, stringWidth, truncateToWidth } from "./width"; import type { StoreResult } from "./store"; import { @@ -177,7 +178,24 @@ process.stdin.on("keypress", (text: string, key: readline.Key) => { return; } if (key.name === "escape") return exit(0); - if (key.name === "backspace") { + const action = resolveEditKey(key); + if (action === "word-left") { + cursor = wordStart(comment, cursor); + } else if (action === "word-right") { + cursor = wordEnd(comment, cursor); + } else if (action === "line-start") { + cursor = lineStart(comment, cursor); + } else if (action === "line-end") { + cursor = lineEnd(comment, cursor); + } else if (action === "delete-word") { + const start = wordStart(comment, cursor); + comment.splice(start, cursor - start); + cursor = start; + } else if (action === "delete-line") { + const start = lineStart(comment, cursor); + comment.splice(start, cursor - start); + cursor = start; + } else if (key.name === "backspace") { if (cursor > 0) comment.splice(--cursor, 1); } else if (key.name === "delete") { if (cursor < comment.length) comment.splice(cursor, 1); diff --git a/test/edit-keys.test.ts b/test/edit-keys.test.ts new file mode 100644 index 0000000..d10a17d --- /dev/null +++ b/test/edit-keys.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { lineEnd, lineStart, resolveEditKey, wordEnd, wordStart } from "../src/edit-keys"; + +const chars = (text: string) => Array.from(text); + +describe("word boundaries", () => { + test("wordStart skips trailing spaces then the word", () => { + const c = chars("foo bar "); + expect(wordStart(c, c.length)).toBe(4); + expect(wordStart(c, 4)).toBe(0); + expect(wordStart(c, 0)).toBe(0); + }); + + test("wordStart stops at a newline before crossing it", () => { + const c = chars("foo\nbar"); + expect(wordStart(c, 4)).toBe(3); + expect(wordStart(c, 3)).toBe(0); + }); + + test("wordEnd skips leading spaces then the word", () => { + const c = chars(" foo bar"); + expect(wordEnd(c, 0)).toBe(5); + expect(wordEnd(c, 5)).toBe(9); + expect(wordEnd(c, 9)).toBe(9); + }); + + test("wordEnd stops after a newline", () => { + const c = chars("foo\nbar"); + expect(wordEnd(c, 3)).toBe(4); + }); + + test("CJK runs count as one word", () => { + const c = chars("한글 테스트"); + expect(wordStart(c, c.length)).toBe(3); + expect(wordEnd(c, 0)).toBe(2); + }); +}); + +describe("line boundaries", () => { + test("lineStart and lineEnd stay within the current line", () => { + const c = chars("ab\ncd\nef"); + expect(lineStart(c, 4)).toBe(3); + expect(lineEnd(c, 4)).toBe(5); + expect(lineStart(c, 0)).toBe(0); + expect(lineEnd(c, 8)).toBe(8); + }); +}); + +describe("resolveEditKey", () => { + test("readline kill bindings (Cmd/Opt+Backspace via Ghostty)", () => { + expect(resolveEditKey({ ctrl: true, name: "u", sequence: "\x15" })).toBe("delete-line"); + expect(resolveEditKey({ ctrl: true, name: "w", sequence: "\x17" })).toBe("delete-word"); + expect(resolveEditKey({ meta: true, name: "backspace", sequence: "\x1b\x7f" })).toBe("delete-word"); + }); + + test("Option arrows move by word", () => { + expect(resolveEditKey({ meta: true, name: "left", sequence: "\x1b[1;3D" })).toBe("word-left"); + expect(resolveEditKey({ meta: true, name: "right", sequence: "\x1b[1;3C" })).toBe("word-right"); + expect(resolveEditKey({ meta: true, name: "b", sequence: "\x1bb" })).toBe("word-left"); + expect(resolveEditKey({ meta: true, name: "f", sequence: "\x1bf" })).toBe("word-right"); + }); + + test("Command arrows move to line edges", () => { + expect(resolveEditKey({ meta: true, name: "left", sequence: "\x1b[1;9D" })).toBe("line-start"); + expect(resolveEditKey({ meta: true, name: "right", sequence: "\x1b[1;9C" })).toBe("line-end"); + }); + + test("plain keys are left to the editor", () => { + expect(resolveEditKey({ name: "left", sequence: "\x1b[D" })).toBeNull(); + expect(resolveEditKey({ name: "backspace", sequence: "\x7f" })).toBeNull(); + expect(resolveEditKey({ name: "a", sequence: "a" })).toBeNull(); + }); +});