From e7c2b77354e1829434376a29b90af6ef08137fab Mon Sep 17 00:00:00 2001 From: grey Date: Tue, 9 Jun 2026 05:51:16 -0500 Subject: [PATCH] refactor(editor): split vim stack, handler map, buffer apply layer Split editor-vim-ops into motions vs edits modules, add editor-buffer apply pipeline and NORMAL-mode handler map, wire editor-window through shared buffer state, and add integration tests for >> indent flow. --- README.md | 4 +- docs/AGENTS.md | 17 +- docs/API.md | 17 +- docs/ARCHITECTURE.md | 12 +- docs/DEVELOPMENT.md | 2 +- docs/OVERVIEW.md | 6 +- docs/README.md | 2 +- docs/STYLE_GUIDE.md | 2 +- src/editor-buffer.integration.test.ts | 52 ++ src/editor-buffer.ts | 67 +++ src/editor-normal-handlers.ts | 188 +++++++ ...m-ops.test.ts => editor-vim-edits.test.ts} | 164 +------ src/editor-vim-edits.ts | 260 ++++++++++ src/editor-vim-motions.test.ts | 162 +++++++ src/editor-vim-motions.ts | 213 ++++++++ src/editor-vim-ops.ts | 459 +----------------- src/editor-window.ts | 327 +++---------- 17 files changed, 1066 insertions(+), 888 deletions(-) create mode 100644 src/editor-buffer.integration.test.ts create mode 100644 src/editor-buffer.ts create mode 100644 src/editor-normal-handlers.ts rename src/{editor-vim-ops.test.ts => editor-vim-edits.test.ts} (51%) create mode 100644 src/editor-vim-edits.ts create mode 100644 src/editor-vim-motions.test.ts create mode 100644 src/editor-vim-motions.ts diff --git a/README.md b/README.md index 6229ce6..91a0261 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A personal portfolio built as an **in-browser desktop OS** — tiling window man | **Stack** | TypeScript · Vite 8 · vanilla DOM (no framework) | | **Terminal** | [@xterm/xterm](https://github.com/xtermjs/xterm.js) + vim input layer | | **3D** | Three.js (Rubik cube — lazy chunk only) | -| **Tests** | **583** unit · **3** e2e smoke · CI on every `main` push | +| **Tests** | **587** unit · **3** e2e smoke · CI on every `main` push | | **Deploy** | GitHub Actions → GitHub Pages (`dist/`) | --- @@ -33,7 +33,7 @@ Most portfolios list skills. This one **runs** them: modular TypeScript, lazy co ```bash npm install npm run dev # desktop → http://localhost:5173/ -npm test # 583 unit tests +npm test # 587 unit tests npm run build && npm run test:e2e ``` diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 90230f1..c8491d2 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -52,7 +52,11 @@ static/index.html | `commands/app-commands.ts` | Tile stubs returning `[]` | | `os-fs.ts` | VFS — key `portfolio-vfs-v8-namefailed-home` | | `os-registry.ts` | Breaks circular import: terminal → desktop ref | -| `editor-vim-ops.ts` | Pure editor motions — **preferred home for new vim text helpers** | +| `editor-vim-motions.ts` | Pure caret/motion helpers — **no buffer mutation** | +| `editor-vim-edits.ts` | Pure `{ text, pos }` buffer edits — `BufferEditResult` | +| `editor-vim-ops.ts` | Barrel re-export of motions + edits | +| `editor-buffer.ts` | Apply layer between pure edits and textarea state | +| `editor-normal-handlers.ts` | NORMAL-mode single-key handler map | | `editor-vim-keys.ts` | Pure editor key-chord helpers | | `vim.ts` | Terminal one-line vim widget (separate from editor tile) | | `bsp-layout.ts` | Two-column BSP + splitters | @@ -73,7 +77,7 @@ static/index.html | `focusedId === null` | No right-pane tile focused (not “legacy terminal focused”) | | `#panes` contains only `#right-pane` | Terminal is a tile, not static HTML column | | BSP `maxVisible = 4` | Fifth tile bumps oldest to dock | -| Editor buffer mutations | Prefer `editor-vim-ops.ts` + tests before touching `editor-window.ts` | +| Editor buffer mutations | Prefer `editor-vim-edits.ts` + `editor-vim-motions.ts` + tests before touching `editor-window.ts` | --- @@ -111,12 +115,13 @@ Prefer editing the extracted module, not `desktop.ts`: | Shell CSS dataset | `desktop-wm-sync.ts` | | Host bindings | `desktop-wm-hosts.ts` | -### Editor vim motion +### Editor vim motion or edit ``` -1. Add pure function to editor-vim-ops.ts -2. Test in editor-vim-ops.test.ts -3. Wire one-liner in editor-window.ts +1. Add pure function to editor-vim-motions.ts (caret) or editor-vim-edits.ts (mutation) +2. Test in editor-vim-motions.test.ts or editor-vim-edits.test.ts +3. Wire handler in editor-normal-handlers.ts OR chord in editor-window.ts +4. Buffer apply goes through editor-buffer.ts when mutating textarea state ``` --- diff --git a/docs/API.md b/docs/API.md index 14e3f4d..08dbd75 100644 --- a/docs/API.md +++ b/docs/API.md @@ -250,17 +250,24 @@ class VimInput { ## Editor vim ops (tile) -Pure functions in `editor-vim-ops.ts` — safe to unit test without DOM: +Split pure modules — safe to unit test without DOM: + +| Module | Exports | +|--------|---------| +| `editor-vim-motions.ts` | Caret positions, word/find motions — **no buffer mutation** | +| `editor-vim-edits.ts` | `{ text, pos }` mutations — `BufferEditResult` contract | +| `editor-vim-ops.ts` | Barrel re-export of both | +| `editor-buffer.ts` | `applyBufferEditToState`, `runIndentBufferEdit` — shared apply layer | +| `editor-normal-handlers.ts` | `EDITOR_NORMAL_KEY_HANDLERS`, `dispatchEditorNormalKey` | ```typescript -// Motions +// Motions (editor-vim-motions.ts) moveVertPos / moveVertRepeat / moveHorizPos wordForwardPos / wordBackPos / wordEndForwardPos -wordForwardRepeat / wordBackRepeat / wordEndForwardRepeat findNextOnLine / repeatFindPos / reverseFindKind lineEndCaretPos / appendLineEndPos / firstNonBlankOnLine -// Edits +// Edits (editor-vim-edits.ts) indentLinesText / unindentLinesText // >> / << toggleCaseRunText // ~ substituteCharsText // s @@ -274,6 +281,8 @@ applyReplaceRunsText / pasteYankText Key chords: `editor-vim-keys.ts` — `insertModeKeyAction`, `tryAppendCountDigit`. +Multi-key chords (`gg`, `dd`, `>>`, find-await) remain in `editor-window.ts`. + Ex commands: `parseEditorExCommand()` in `editor-ex-commands.ts`. --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7aebdbd..7730054 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -111,8 +111,12 @@ Three.js ships **only** in the `rubik-window` lazy chunk. | Module | Responsibility | |--------|----------------| -| `editor-window.ts` | DOM, modes, key routing, VFS I/O | -| `editor-vim-ops.ts` | Pure text/cursor/motion helpers (unit tested) | +| `editor-window.ts` | DOM, modes, chord routing, VFS I/O | +| `editor-normal-handlers.ts` | NORMAL-mode single-key handler map | +| `editor-buffer.ts` | Buffer state apply layer (textarea ↔ pure edits) | +| `editor-vim-ops.ts` | Barrel re-export of motions + edits | +| `editor-vim-motions.ts` | Pure caret/motion helpers (no mutation) | +| `editor-vim-edits.ts` | Pure `{ text, pos }` buffer mutations | | `editor-vim-keys.ts` | Pure INSERT/NORMAL key-chord helpers | | `editor-ex-commands.ts` | `:w` / `:q` / `:e` ex-mode parsing | | `editor-window-meta.ts` | Path compare, title strings | @@ -228,14 +232,14 @@ Bump VFS key version in `os-fs.ts` to reset visitor filesystems. ## Testing -**583 tests** · **46 files** · Vitest in Node · Playwright e2e smoke. +**587 tests** · **48 files** · Vitest in Node · Playwright e2e smoke. ### By domain | Domain | Example test files | |--------|-------------------| | WM / desktop | `desktop.test.ts`, `desktop-wm-*.test.ts`, `desktop-keyboard-handler.test.ts`, `bsp-layout.test.ts` | -| Editor vim | `editor-vim-ops.test.ts`, `editor-vim-keys.test.ts`, `editor-ex-commands.test.ts` | +| Editor vim | `editor-vim-motions.test.ts`, `editor-vim-edits.test.ts`, `editor-buffer.integration.test.ts`, `editor-vim-keys.test.ts`, `editor-ex-commands.test.ts` | | Terminal / vim | `vim.test.ts`, `terminal-motd.test.ts` | | VFS / commands | `os-fs.test.ts`, `commands/*.test.ts` | | Tiles / launcher | `launcher-catalog.test.ts`, `desktop-open-window.test.ts`, `window-chrome.test.ts` | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0efceea..184476b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -40,7 +40,7 @@ Run before opening a PR: ```bash npm run lint # ESLint -npm test # Vitest — 583 unit tests +npm test # Vitest — 587 unit tests npm run build # TypeScript + Vite npm run test:e2e # Playwright (requires preview build) ``` diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 35ba633..b1e8a84 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -88,9 +88,9 @@ Each window tile (`editor-window.ts`, `rubik-window.ts`, etc.) is loaded with ** | Layer | Module | Purpose | |-------|--------|---------| | Terminal one-liner | `vim.ts` | Shell prompt — insert/normal/visual, history, completion | -| Modal editor tile | `editor-window.ts` + `editor-vim-ops.ts` | Full buffer editor over the VFS — motions, operators, ex commands | +| Modal editor tile | `editor-window.ts` + vim stack | Full buffer editor over the VFS — motions, operators, ex commands | -Pure caret helpers in `editor-vim-ops.ts` are **unit-tested without a DOM**, which keeps editor logic maintainable. +Pure caret and edit helpers (`editor-vim-motions.ts`, `editor-vim-edits.ts`) are **unit-tested without a DOM**, which keeps editor logic maintainable. ### Virtual filesystem (VFS v8) @@ -100,7 +100,7 @@ Files persist in `localStorage` under `portfolio-vfs-v8-namefailed-home`. First | Stage | Tool | |-------|------| -| Unit | Vitest — **583 tests**, **46 files**, Node environment with DOM stubs where needed | +| Unit | Vitest — **587 tests**, **48 files**, Node environment with DOM stubs where needed | | Lint | ESLint 9 + TypeScript-eslint | | E2e | Playwright — production build smoke (desktop shell, brochure, nav) | | Deploy | GitHub Actions → `dist/` → GitHub Pages | diff --git a/docs/README.md b/docs/README.md index f223237..c52255d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,7 +32,7 @@ Personal portfolio site ([mrgrey.site](https://mrgrey.site)) built as an in-brow | Metric | Value | |--------|-------| -| Unit tests | **583** across **46** files (`npm test`) | +| Unit tests | **587** across **48** files (`npm test`) | | E2e smoke | **3** Playwright specs (`npm run test:e2e`) | | CI | lint → unit tests → build → e2e → GitHub Pages deploy | | Stack | TypeScript, Vite 8, vanilla DOM — no React/Vue/Svelte | diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md index 037e1a8..74f8e3d 100644 --- a/docs/STYLE_GUIDE.md +++ b/docs/STYLE_GUIDE.md @@ -479,7 +479,7 @@ el.innerHTML = escapeHtml(userInput) ## Tooling - `npm run lint` — ESLint (TypeScript, e2e specs, maintainer scripts) -- `npm test` — **583** Vitest unit tests (46 files) +- `npm test` — **587** Vitest unit tests (48 files) - `npm run test:coverage` — Vitest v8 coverage (`coverage/` gitignored) - `npm run test:e2e` — Playwright smoke against `vite preview` - Desktop CSS: `src/styles/*.css` via `src/style.css` hub; regenerate with `node scripts/split-style-css.mjs` diff --git a/src/editor-buffer.integration.test.ts b/src/editor-buffer.integration.test.ts new file mode 100644 index 0000000..673a1d9 --- /dev/null +++ b/src/editor-buffer.integration.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { + applyBufferEditToState, + applyStateToTextarea, + bufferStateFromTextarea, + runIndentBufferEdit, + type EditorBufferState, + type TextareaLike, +} from './editor-buffer' + +function fakeTextarea(value: string, pos: number): TextareaLike { + return { value, selectionStart: pos, selectionEnd: pos } +} + +describe('editor-buffer integration', () => { + it('>> indents from caret line through apply pipeline', () => { + const textarea = fakeTextarea(' foo\nbar', 2) + const savedText = ' foo\nbar' + const state = bufferStateFromTextarea(textarea, savedText, false) + + expect(runIndentBufferEdit(state, 1)).toBe(true) + expect(state.text).toBe(' foo\nbar') + expect(state.selectionStart).toBe(4) + expect(state.dirty).toBe(true) + + applyStateToTextarea(textarea, state) + expect(textarea.value).toBe(' foo\nbar') + expect(textarea.selectionStart).toBe(4) + }) + + it('applyBufferEditToState is a no-op for null edits', () => { + const state: EditorBufferState = { + text: 'hello', + selectionStart: 0, + selectionEnd: 0, + savedText: 'hello', + dirty: false, + } + expect(applyBufferEditToState(state, null)).toBe(false) + expect(state.text).toBe('hello') + }) + + it('2>> indents multiple lines from caret', () => { + const textarea = fakeTextarea('a\nb\nc', 2) + const state = bufferStateFromTextarea(textarea, 'a\nb\nc', false) + + expect(runIndentBufferEdit(state, 2)).toBe(true) + expect(state.text).toBe('a\n b\n c') + applyStateToTextarea(textarea, state) + expect(textarea.value).toBe('a\n b\n c') + }) +}) diff --git a/src/editor-buffer.ts b/src/editor-buffer.ts new file mode 100644 index 0000000..bd47bd8 --- /dev/null +++ b/src/editor-buffer.ts @@ -0,0 +1,67 @@ +/** + * Shared buffer-apply layer between pure vim edits and the editor tile. + * + * Contract: `applyBufferEditToState` writes text + selection and sets `dirty`. + * Undo snapshots and DOM updates remain the caller's responsibility. + */ + +import type { BufferEditResult } from './editor-vim-edits' +import { indentLinesText } from './editor-vim-edits' + +export type { BufferEditResult } + +export interface EditorBufferState { + text: string + selectionStart: number + selectionEnd: number + savedText: string + dirty: boolean +} + +/** Minimal textarea surface used by integration tests and EditorWindow. */ +export interface TextareaLike { + value: string + selectionStart: number + selectionEnd: number +} + +export function bufferStateFromTextarea( + textarea: TextareaLike, + savedText: string, + dirty: boolean, +): EditorBufferState { + return { + text: textarea.value, + selectionStart: textarea.selectionStart, + selectionEnd: textarea.selectionEnd, + savedText, + dirty, + } +} + +export function applyStateToTextarea(textarea: TextareaLike, state: EditorBufferState): void { + textarea.value = state.text + textarea.selectionStart = state.selectionStart + textarea.selectionEnd = state.selectionEnd +} + +/** Apply a pure edit result; returns false when `result` is null/undefined. */ +export function applyBufferEditToState( + state: EditorBufferState, + result: BufferEditResult | null | undefined, +): boolean { + if (!result) return false + state.text = result.text + state.selectionStart = result.pos + state.selectionEnd = result.pos + state.dirty = result.text !== state.savedText + return true +} + +/** `>>` pipeline: indent N lines from caret — used by handlers and integration tests. */ +export function runIndentBufferEdit(state: EditorBufferState, nLines: number): boolean { + return applyBufferEditToState( + state, + indentLinesText(state.text, state.selectionStart, nLines), + ) +} diff --git a/src/editor-normal-handlers.ts b/src/editor-normal-handlers.ts new file mode 100644 index 0000000..feb1cd0 --- /dev/null +++ b/src/editor-normal-handlers.ts @@ -0,0 +1,188 @@ +/** + * NORMAL-mode single-key dispatch for the modal editor. + * + * Multi-key chords (`gg`, `>>`, `dd`, count prefixes, find-await) stay in + * `editor-window.ts`. This map handles stable one-stroke bindings. + */ + +import type { BufferEditResult } from './editor-vim-edits' +import { + deleteCharBackwardText, + deleteCharForwardText, + deleteThroughEOLText, + openLineAboveText, + openLineBelowText, + substituteCharsText, + toggleCaseRunText, + yankToEOLText, +} from './editor-vim-edits' +import { + appendLineEndPos, + firstNonBlankOnLine, + lineBounds, + lineEndCaretPos, + moveHorizPos, + moveVertRepeat, + wordBackRepeat, + wordEndForwardRepeat, + wordForwardRepeat, +} from './editor-vim-motions' + +export interface EditorNormalCtx { + readonly key: string + prevent(): void + cur(): number + setCur(pos: number): void + text(): string + consumeCount(defaultN?: number): number + consumeOptionalNat(): number | null + applyEdit(result: BufferEditResult | null | undefined, opts?: { enterInsert?: boolean }): boolean + enterInsert(): void + enterInsertAt(pos: number): void + gotoLine(oneBased: number): void + gotoLastLine(): void + deleteThroughEOL(): void + joinBelow(): void + yankRegister: string + flash(msg: string): void + armReplace(nRuns: number): void + undo(): void + paste(times: number, afterLine: boolean): void +} + +export type EditorNormalHandler = (ctx: EditorNormalCtx) => void + +function motion(ctx: EditorNormalCtx, pos: number): void { + ctx.prevent() + ctx.setCur(pos) +} + +function edit( + ctx: EditorNormalCtx, + result: BufferEditResult | null | undefined, + opts?: { enterInsert?: boolean }, +): void { + ctx.prevent() + ctx.applyEdit(result, opts) +} + +export const EDITOR_NORMAL_HANDLERS: Readonly> = { + '0': ctx => { + ctx.prevent() + ctx.consumeCount(1) + ctx.setCur(lineBounds(ctx.text(), ctx.cur()).start) + }, + '^': ctx => { + ctx.prevent() + ctx.consumeCount(1) + ctx.setCur(firstNonBlankOnLine(ctx.text(), ctx.cur())) + }, + $: ctx => { + ctx.prevent() + ctx.consumeCount(1) + ctx.setCur(lineEndCaretPos(ctx.text(), ctx.cur())) + }, + G: ctx => { + ctx.prevent() + const n = ctx.consumeOptionalNat() + if (n == null) ctx.gotoLastLine() + else ctx.gotoLine(n) + }, + D: ctx => { + ctx.prevent() + ctx.consumeCount(1) + ctx.deleteThroughEOL() + }, + C: ctx => { + ctx.prevent() + ctx.consumeCount(1) + edit(ctx, deleteThroughEOLText(ctx.text(), ctx.cur()), { enterInsert: true }) + }, + s: ctx => { + ctx.prevent() + edit(ctx, substituteCharsText(ctx.text(), ctx.cur(), ctx.consumeCount(1)), { enterInsert: true }) + }, + '~': ctx => { + ctx.prevent() + edit(ctx, toggleCaseRunText(ctx.text(), ctx.cur(), ctx.consumeCount(1))) + }, + Y: ctx => { + ctx.prevent() + ctx.consumeCount(1) + ctx.yankRegister = yankToEOLText(ctx.text(), ctx.cur()) + ctx.flash('Yanked to end of line') + }, + J: ctx => { + ctx.prevent() + ctx.joinBelow() + }, + r: ctx => { + ctx.prevent() + ctx.armReplace(ctx.consumeCount(1)) + }, + X: ctx => { + ctx.prevent() + edit(ctx, deleteCharBackwardText(ctx.text(), ctx.cur(), ctx.consumeCount(1))) + }, + x: ctx => { + ctx.prevent() + edit(ctx, deleteCharForwardText(ctx.text(), ctx.cur(), ctx.consumeCount(1))) + }, + w: ctx => motion(ctx, wordForwardRepeat(ctx.text(), ctx.cur(), ctx.consumeCount(1))), + b: ctx => motion(ctx, wordBackRepeat(ctx.text(), ctx.cur(), ctx.consumeCount(1))), + e: ctx => motion(ctx, wordEndForwardRepeat(ctx.text(), ctx.cur(), ctx.consumeCount(1))), + h: ctx => motion(ctx, moveHorizPos(ctx.text(), ctx.cur(), -1, ctx.consumeCount(1))), + l: ctx => motion(ctx, moveHorizPos(ctx.text(), ctx.cur(), 1, ctx.consumeCount(1))), + j: ctx => motion(ctx, moveVertRepeat(ctx.text(), ctx.cur(), 1, ctx.consumeCount(1))), + k: ctx => motion(ctx, moveVertRepeat(ctx.text(), ctx.cur(), -1, ctx.consumeCount(1))), + i: ctx => { + ctx.prevent() + ctx.consumeCount(1) + ctx.enterInsert() + }, + a: ctx => { + ctx.prevent() + ctx.consumeCount(1) + ctx.enterInsertAt(ctx.cur() + 1) + }, + A: ctx => { + ctx.prevent() + ctx.consumeCount(1) + ctx.enterInsertAt(appendLineEndPos(ctx.text(), ctx.cur())) + }, + I: ctx => { + ctx.prevent() + ctx.consumeCount(1) + ctx.enterInsertAt(firstNonBlankOnLine(ctx.text(), ctx.cur())) + }, + o: ctx => { + ctx.prevent() + ctx.consumeCount(1) + edit(ctx, openLineBelowText(ctx.text(), ctx.cur()), { enterInsert: true }) + }, + O: ctx => { + ctx.prevent() + ctx.consumeCount(1) + edit(ctx, openLineAboveText(ctx.text(), ctx.cur()), { enterInsert: true }) + }, + u: ctx => { + ctx.prevent() + ctx.undo() + }, + p: ctx => { + ctx.prevent() + ctx.paste(ctx.consumeCount(1), true) + }, + P: ctx => { + ctx.prevent() + ctx.paste(ctx.consumeCount(1), false) + }, +} + +/** Returns true when a handler consumed the key. */ +export function dispatchEditorNormalKey(ctx: EditorNormalCtx): boolean { + const handler = EDITOR_NORMAL_HANDLERS[ctx.key] + if (!handler) return false + handler(ctx) + return true +} diff --git a/src/editor-vim-ops.test.ts b/src/editor-vim-edits.test.ts similarity index 51% rename from src/editor-vim-ops.test.ts rename to src/editor-vim-edits.test.ts index ce0b7f1..af305ba 100644 --- a/src/editor-vim-ops.test.ts +++ b/src/editor-vim-edits.test.ts @@ -1,133 +1,22 @@ import { describe, it, expect } from 'vitest' import { applyReplaceRunsText, - appendLineEndPos, - consumeCountDigits, deleteCharBackwardText, deleteCharForwardText, deleteLineBlockText, - findNextOnLine, - getLineCol, - gotoLinePos, + deleteThroughEOLText, indentLinesText, joinLinesText, - lineBounds, - lineCountTotal, - lineEndCaretPos, - moveHorizPos, - moveVertPos, - moveVertRepeat, openLineAboveText, openLineBelowText, pasteYankText, - repeatFindPos, - reverseFindKind, substituteCharsText, toggleCaseRunText, unindentLinesText, - wordBackPos, - wordBackRepeat, - wordEndForwardPos, - wordEndForwardRepeat, - wordForwardPos, - wordForwardRepeat, yankLineBlockText, yankToEOLText, -} from './editor-vim-ops' -import { insertModeKeyAction, tryAppendCountDigit } from './editor-vim-keys' - -describe('lineCountTotal', () => { - it('returns 1 for empty buffer', () => { - expect(lineCountTotal('')).toBe(1) - }) - - it('counts newline-separated lines', () => { - expect(lineCountTotal('a\nb\nc')).toBe(3) - }) -}) - -describe('getLineCol', () => { - it('reports 1-based line and column', () => { - expect(getLineCol('ab\ncd', 4)).toEqual({ line: 2, col: 2 }) - }) -}) - -describe('lineBounds', () => { - it('returns start/end without trailing newline', () => { - expect(lineBounds('aa\nbb', 3)).toEqual({ start: 3, end: 5 }) - }) -}) - -describe('gotoLinePos', () => { - it('jumps to first char of requested line', () => { - expect(gotoLinePos('one\ntwo\nthree', 2)).toBe(4) - }) -}) - -describe('consumeCountDigits', () => { - it('defaults when empty', () => { - expect(consumeCountDigits('')).toBe(1) - expect(consumeCountDigits('3')).toBe(3) - }) -}) - -describe('moveVertPos', () => { - it('moves down preserving 0-based column offset (clamped to line length)', () => { - const text = 'ab\nc\nde' - expect(moveVertPos(text, 1, 1)).toBe(4) - expect(moveVertPos(text, 0, 1)).toBe(3) - }) -}) - -describe('moveHorizPos', () => { - it('steps left and right with clamping', () => { - expect(moveHorizPos('abc', 1, -1, 2)).toBe(0) - expect(moveHorizPos('abc', 1, 1, 2)).toBe(3) - }) -}) - -describe('lineEndCaretPos', () => { - it('lands on last character before newline', () => { - expect(lineEndCaretPos('hello\nworld', 2)).toBe(4) - }) -}) - -describe('word motions', () => { - const text = 'foo bar baz' - - it('wordForwardPos skips token and punctuation', () => { - expect(wordForwardPos(text, 0)).toBe(3) - expect(wordForwardPos(text, 4)).toBe(7) - expect(wordForwardPos(text, 8)).toBe(11) - }) - - it('wordBackPos finds previous token start', () => { - expect(wordBackPos(text, 11)).toBe(8) - expect(wordBackPos(text, 8)).toBe(4) - }) - - it('wordEndForwardPos finds end of current/next token', () => { - expect(wordEndForwardPos(text, 0)).toBe(2) - expect(wordEndForwardPos(text, 8)).toBe(10) - }) -}) - -describe('findNextOnLine', () => { - const line = 'alpha beta gamma' - - it('finds forward character', () => { - expect(findNextOnLine(line, 'f', 'b', 0)).toBe(6) - }) - - it('finds backward character', () => { - expect(findNextOnLine(line, 'F', 'b', 12)).toBe(6) - }) - - it('reverseFindKind swaps directions', () => { - expect(reverseFindKind('f')).toBe('F') - expect(reverseFindKind('t')).toBe('T') - }) -}) +} from './editor-vim-edits' +import { appendLineEndPos } from './editor-vim-motions' describe('deleteLineBlockText', () => { it('removes lines and leaves caret at block start', () => { @@ -250,39 +139,13 @@ describe('openLineBelowText / openLineAboveText', () => { }) }) -describe('repeatFindPos', () => { - it('repeats f motion', () => { - expect(repeatFindPos('ab ab ab', 2, 'f', 'a', 0)).toBe(6) - }) - - it('returns null when motion fails', () => { - expect(repeatFindPos('xyz', 1, 'f', 'q', 0)).toBeNull() +describe('deleteThroughEOLText', () => { + it('deletes from caret through end of line', () => { + expect(deleteThroughEOLText('hello\nworld', 1)).toEqual({ text: 'h\nworld', pos: 1 }) }) }) -describe('moveVertRepeat', () => { - it('steps j/k multiple times', () => { - expect(moveVertRepeat('a\nb\nc', 0, 1, 2)).toBe(4) - }) -}) - -describe('word repeats', () => { - const text = 'foo bar baz' - - it('wordForwardRepeat', () => { - expect(wordForwardRepeat(text, 0, 2)).toBe(7) - }) - - it('wordBackRepeat', () => { - expect(wordBackRepeat(text, 11, 2)).toBe(4) - }) - - it('wordEndForwardRepeat', () => { - expect(wordEndForwardRepeat(text, 4, 2)).toBe(6) - }) -}) - -describe('vim editing scenarios', () => { +describe('vim edit scenarios', () => { it('yank-delete-paste round trip restores buffer', () => { const original = 'line1\nline2\nline3' const yanked = yankLineBlockText(original, 2, 1) @@ -294,17 +157,4 @@ describe('vim editing scenarios', () => { const pasted = pasteYankText(deleted!.text, 0, yanked!.yank, true) expect(pasted?.text).toBe(original) }) - - it('insert mode Esc is detected before normal-mode count keys', () => { - expect(insertModeKeyAction('Escape', { ctrlKey: false, metaKey: false })).toBe('leave-normal') - expect(tryAppendCountDigit('', '3')).toBe('3') - }) - - it('find motion chains forward on repeated f', () => { - const text = 'ab ab ab' - const first = findNextOnLine(text, 'f', 'a', 0) - expect(first).toBe(3) - const second = findNextOnLine(text, 'f', 'a', first!) - expect(second).toBe(6) - }) }) diff --git a/src/editor-vim-edits.ts b/src/editor-vim-edits.ts new file mode 100644 index 0000000..b492d6c --- /dev/null +++ b/src/editor-vim-edits.ts @@ -0,0 +1,260 @@ +/** + * Pure buffer mutation helpers for the modal editor (vim-like). + * + * Contract: every edit returns `{ text, pos }` or `null` when a no-op. + * Callers (`editor-window`, tests) own DOM, undo stack, and mode transitions. + */ + +import { getLineCol, lineBounds } from './editor-vim-motions' + +export type BufferEditResult = { text: string; pos: number } + +/** Byte span covering `nLines` lines starting at `startLineIdx` (0-based). */ +export function lineBlockSpan( + text: string, + startLineIdx: number, + nLines: number, +): { start: number; end: number; lineCount: number } | null { + const lines = text.split('\n') + if (!lines.length || startLineIdx < 0 || startLineIdx >= lines.length) return null + const lineCount = Math.max(1, Math.min(nLines, lines.length - startLineIdx)) + let start = 0 + for (let i = 0; i < startLineIdx; i++) start += lines[i]!.length + 1 + let end = start + for (let i = startLineIdx; i < startLineIdx + lineCount; i++) { + end += lines[i]!.length + if (i < lines.length - 1) end += 1 + } + return { start, end, lineCount } +} + +export function deleteLineBlockText( + text: string, + curLineOneBased: number, + nLines: number, +): BufferEditResult | null { + const span = lineBlockSpan(text, curLineOneBased - 1, nLines) + if (!span) return null + const next = text.slice(0, span.start) + text.slice(span.end) + return { text: next, pos: Math.min(span.start, next.length) } +} + +export function yankLineBlockText( + text: string, + curLineOneBased: number, + nLines: number, +): { yank: string; lineCount: number } | null { + const span = lineBlockSpan(text, curLineOneBased - 1, nLines) + if (!span) return null + let yank = text.slice(span.start, span.end) + if (!yank.endsWith('\n')) yank += '\n' + return { yank, lineCount: span.lineCount } +} + +export function deleteThroughEOLText(text: string, pos: number): BufferEditResult { + const p = Math.min(Math.max(0, pos), text.length) + const { end } = lineBounds(text, p) + const next = text.slice(0, p) + text.slice(end) + return { text: next, pos: p } +} + +/** Minimal vi `J` — join `span` consecutive lines without extra spacing. */ +export function joinLinesText( + text: string, + curLineOneBased: number, + span: number, +): BufferEditResult | null { + const lines = text.split('\n') + const cur = curLineOneBased - 1 + if (cur < 0 || cur >= lines.length - 1) return null + const maxSpan = Math.min(Math.max(2, span), lines.length - cur) + const merged = lines.slice(cur, cur + maxSpan).join('') + const block = lineBlockSpan(text, cur, maxSpan) + if (!block) return null + const next = text.slice(0, block.start) + merged + text.slice(block.end) + return { text: next, pos: Math.min(block.start + merged.length, next.length) } +} + +export function applyReplaceRunsText( + text: string, + pos: number, + n: number, + ch: string, +): BufferEditResult | null { + const p = Math.min(Math.max(0, pos), text.length) + const take = Math.min(n, Math.max(0, text.length - p)) + if (take <= 0) return null + const rep = ch.repeat(take) + const next = text.slice(0, p) + rep + text.slice(p + take) + const c = Math.max(p, Math.min(p + take - 1, next.length - 1)) + return { text: next, pos: c } +} + +/** `p` paste after current line; `P` paste before current line. */ +export function pasteYankText( + text: string, + pos: number, + yank: string, + afterLine: boolean, +): BufferEditResult | null { + if (!yank) return null + const { start, end } = lineBounds(text, pos) + const ins = afterLine + ? end < text.length && text[end] === '\n' + ? end + 1 + : text.length + : start + const next = text.slice(0, ins) + yank + text.slice(ins) + return { text: next, pos: ins + yank.length - 1 } +} + +/** Default `>>` / `<<` shift width (two spaces). */ +export const EDITOR_INDENT = ' ' + +function leadingUnindentWidth(line: string): number { + if (line.startsWith(' ')) return 2 + if (line.startsWith('\t')) return 1 + if (line.startsWith(' ')) return 1 + return 0 +} + +/** `>>` — indent `nLines` from caret line downward. */ +export function indentLinesText( + text: string, + pos: number, + nLines: number, + indent = EDITOR_INDENT, +): BufferEditResult | null { + const p0 = Math.min(Math.max(0, pos), text.length) + const li = text.slice(0, p0).split('\n').length - 1 + const lines = text.split('\n') + if (li < 0 || li >= lines.length) return null + const take = Math.max(1, Math.min(nLines, lines.length - li)) + for (let j = 0; j < take; j++) lines[li + j] = indent + (lines[li + j] ?? '') + const next = lines.join('\n') + const curLineIdx = text.slice(0, p0).split('\n').length - 1 + let newP = p0 + if (curLineIdx >= li && curLineIdx < li + take) newP += indent.length + return { text: next, pos: newP } +} + +/** `<<` — unindent `nLines` from caret line downward. */ +export function unindentLinesText( + text: string, + pos: number, + nLines: number, +): BufferEditResult | null { + const p0 = Math.min(Math.max(0, pos), text.length) + const li = text.slice(0, p0).split('\n').length - 1 + const lines = text.split('\n') + if (li < 0 || li >= lines.length) return null + const take = Math.max(1, Math.min(nLines, lines.length - li)) + const curLineIdx = text.slice(0, p0).split('\n').length - 1 + let removedBefore = 0 + for (let j = 0; j < take; j++) { + const idx = li + j + const s = lines[idx] ?? '' + const cut = leadingUnindentWidth(s) + if (cut && idx === curLineIdx) { + const lineStart = text.lastIndexOf('\n', p0 - 1) + 1 + const col = p0 - lineStart + removedBefore = Math.min(cut, col) + } + lines[idx] = s.slice(cut) + } + const next = lines.join('\n') + return { text: next, pos: Math.max(0, p0 - removedBefore) } +} + +/** `~` — toggle case on next `n` non-newline characters. */ +export function toggleCaseRunText( + text: string, + pos: number, + n: number, +): BufferEditResult | null { + let p = Math.min(Math.max(0, pos), text.length) + let buf = text + let toggled = 0 + while (toggled < n && p < buf.length) { + const ch = buf[p]! + if (ch === '\n') { + p++ + continue + } + const repl = ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase() + buf = buf.slice(0, p) + repl + buf.slice(p + 1) + p++ + toggled++ + } + if (buf === text) return null + return { text: buf, pos: Math.min(p, buf.length) } +} + +/** `s` — delete `n` characters under cursor (enter insert in caller). */ +export function substituteCharsText( + text: string, + pos: number, + n: number, +): BufferEditResult | null { + const take = Math.max(1, n) + const p = Math.min(Math.max(0, pos), text.length) + const del = Math.min(take, Math.max(0, text.length - p)) + if (del <= 0) return null + const next = text.slice(0, p) + text.slice(p + del) + return { text: next, pos: p } +} + +/** `x` — delete `n` characters forward from cursor. */ +export function deleteCharForwardText( + text: string, + pos: number, + n: number, +): BufferEditResult | null { + const p = Math.min(Math.max(0, pos), text.length) + const kill = Math.min(Math.max(1, n), Math.max(0, text.length - p)) + if (!kill) return null + const next = text.slice(0, p) + text.slice(p + kill) + return { text: next, pos: p } +} + +/** `X` — delete `n` characters backward from cursor. */ +export function deleteCharBackwardText( + text: string, + pos: number, + n: number, +): BufferEditResult | null { + const p = Math.min(Math.max(0, pos), text.length) + const chop = Math.min(Math.max(1, n), p) + if (!chop) return null + const next = text.slice(0, p - chop) + text.slice(p) + return { text: next, pos: p - chop } +} + +/** `Y` — yank from cursor through end of line (no trailing newline). */ +export function yankToEOLText(text: string, pos: number): string { + const p = Math.min(Math.max(0, pos), text.length) + const { end } = lineBounds(text, p) + return text.slice(p, end) +} + +/** `o` — open new line below current line. */ +export function openLineBelowText(text: string, pos: number): BufferEditResult { + const p = Math.min(Math.max(0, pos), text.length) + const nl = text.indexOf('\n', p) + const insAt = nl === -1 ? text.length : nl + const next = text.slice(0, insAt) + '\n' + text.slice(insAt) + return { text: next, pos: insAt + 1 } +} + +/** `O` — open new line above current line. */ +export function openLineAboveText(text: string, pos: number): BufferEditResult { + const p = Math.min(Math.max(0, pos), text.length) + const lineStart = text.lastIndexOf('\n', p - 1) + 1 + const next = text.slice(0, lineStart) + '\n' + text.slice(lineStart) + return { text: next, pos: lineStart } +} + +/** Line number (1-based) at `pos` — convenience for edit helpers. */ +export function lineIndexOneBased(text: string, pos: number): number { + return getLineCol(text, pos).line +} diff --git a/src/editor-vim-motions.test.ts b/src/editor-vim-motions.test.ts new file mode 100644 index 0000000..93777e4 --- /dev/null +++ b/src/editor-vim-motions.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest' +import { + consumeCountDigits, + findNextOnLine, + getLineCol, + gotoLinePos, + lineBounds, + lineCountTotal, + lineEndCaretPos, + moveHorizPos, + moveVertPos, + moveVertRepeat, + repeatFindPos, + reverseFindKind, + wordBackPos, + wordBackRepeat, + wordEndForwardPos, + wordEndForwardRepeat, + wordForwardPos, + wordForwardRepeat, +} from './editor-vim-motions' +import { insertModeKeyAction, tryAppendCountDigit } from './editor-vim-keys' + +describe('lineCountTotal', () => { + it('returns 1 for empty buffer', () => { + expect(lineCountTotal('')).toBe(1) + }) + + it('counts newline-separated lines', () => { + expect(lineCountTotal('a\nb\nc')).toBe(3) + }) +}) + +describe('getLineCol', () => { + it('reports 1-based line and column', () => { + expect(getLineCol('ab\ncd', 4)).toEqual({ line: 2, col: 2 }) + }) +}) + +describe('lineBounds', () => { + it('returns start/end without trailing newline', () => { + expect(lineBounds('aa\nbb', 3)).toEqual({ start: 3, end: 5 }) + }) +}) + +describe('gotoLinePos', () => { + it('jumps to first char of requested line', () => { + expect(gotoLinePos('one\ntwo\nthree', 2)).toBe(4) + }) +}) + +describe('consumeCountDigits', () => { + it('defaults when empty', () => { + expect(consumeCountDigits('')).toBe(1) + expect(consumeCountDigits('3')).toBe(3) + }) +}) + +describe('moveVertPos', () => { + it('moves down preserving 0-based column offset (clamped to line length)', () => { + const text = 'ab\nc\nde' + expect(moveVertPos(text, 1, 1)).toBe(4) + expect(moveVertPos(text, 0, 1)).toBe(3) + }) +}) + +describe('moveHorizPos', () => { + it('steps left and right with clamping', () => { + expect(moveHorizPos('abc', 1, -1, 2)).toBe(0) + expect(moveHorizPos('abc', 1, 1, 2)).toBe(3) + }) +}) + +describe('lineEndCaretPos', () => { + it('lands on last character before newline', () => { + expect(lineEndCaretPos('hello\nworld', 2)).toBe(4) + }) +}) + +describe('word motions', () => { + const text = 'foo bar baz' + + it('wordForwardPos skips token and punctuation', () => { + expect(wordForwardPos(text, 0)).toBe(3) + expect(wordForwardPos(text, 4)).toBe(7) + expect(wordForwardPos(text, 8)).toBe(11) + }) + + it('wordBackPos finds previous token start', () => { + expect(wordBackPos(text, 11)).toBe(8) + expect(wordBackPos(text, 8)).toBe(4) + }) + + it('wordEndForwardPos finds end of current/next token', () => { + expect(wordEndForwardPos(text, 0)).toBe(2) + expect(wordEndForwardPos(text, 8)).toBe(10) + }) +}) + +describe('findNextOnLine', () => { + const line = 'alpha beta gamma' + + it('finds forward character', () => { + expect(findNextOnLine(line, 'f', 'b', 0)).toBe(6) + }) + + it('finds backward character', () => { + expect(findNextOnLine(line, 'F', 'b', 12)).toBe(6) + }) + + it('reverseFindKind swaps directions', () => { + expect(reverseFindKind('f')).toBe('F') + expect(reverseFindKind('t')).toBe('T') + }) +}) + +describe('repeatFindPos', () => { + it('repeats f motion', () => { + expect(repeatFindPos('ab ab ab', 2, 'f', 'a', 0)).toBe(6) + }) + + it('returns null when motion fails', () => { + expect(repeatFindPos('xyz', 1, 'f', 'q', 0)).toBeNull() + }) +}) + +describe('moveVertRepeat', () => { + it('steps j/k multiple times', () => { + expect(moveVertRepeat('a\nb\nc', 0, 1, 2)).toBe(4) + }) +}) + +describe('word repeats', () => { + const text = 'foo bar baz' + + it('wordForwardRepeat', () => { + expect(wordForwardRepeat(text, 0, 2)).toBe(7) + }) + + it('wordBackRepeat', () => { + expect(wordBackRepeat(text, 11, 2)).toBe(4) + }) + + it('wordEndForwardRepeat', () => { + expect(wordEndForwardRepeat(text, 4, 2)).toBe(6) + }) +}) + +describe('vim motion scenarios', () => { + it('insert mode Esc is detected before normal-mode count keys', () => { + expect(insertModeKeyAction('Escape', { ctrlKey: false, metaKey: false })).toBe('leave-normal') + expect(tryAppendCountDigit('', '3')).toBe('3') + }) + + it('find motion chains forward on repeated f', () => { + const text = 'ab ab ab' + const first = findNextOnLine(text, 'f', 'a', 0) + expect(first).toBe(3) + const second = findNextOnLine(text, 'f', 'a', first!) + expect(second).toBe(6) + }) +}) diff --git a/src/editor-vim-motions.ts b/src/editor-vim-motions.ts new file mode 100644 index 0000000..f06e69a --- /dev/null +++ b/src/editor-vim-motions.ts @@ -0,0 +1,213 @@ +/** + * Pure caret / motion helpers for the modal editor (vim-like). + * + * Contract: functions return indices or scalar positions only — never mutate text. + * Buffer mutations live in `editor-vim-edits.ts`. + */ + +export function lineCountTotal(text: string): number { + if (!text) return 1 + return Math.max(1, text.split('\n').length) +} + +export function getLineCol(text: string, pos: number): { line: number; col: number } { + const p = Math.min(Math.max(0, pos), text.length) + const pref = text.slice(0, p) + const line = pref.split('\n').length + const li = pref.lastIndexOf('\n') + const col = p - (li + 1) + 1 + return { line, col } +} + +export function lineBounds(text: string, pos: number): { start: number; end: number } { + const lineStart = text.lastIndexOf('\n', pos - 1) + 1 + let lineEnd = text.indexOf('\n', pos) + if (lineEnd === -1) lineEnd = text.length + return { start: lineStart, end: lineEnd } +} + +export function gotoLinePos(text: string, oneBased: number): number { + const lines = text.split('\n') + const maxL = Math.max(1, lines.length) + const n = Math.max(1, Math.min(oneBased, maxL)) + let pos = 0 + for (let i = 0; i < n - 1; i++) pos += lines[i]!.length + 1 + return pos +} + +export function consumeCountDigits(digits: string, defaultN = 1): number { + if (!digits) return Math.max(1, defaultN) + const v = parseInt(digits, 10) + if (!Number.isFinite(v) || v < 1) return Math.max(1, defaultN) + return Math.min(v, 50_000) +} + +export function consumeOptionalNat(digits: string): number | null { + if (!digits) return null + const v = parseInt(digits, 10) + if (!Number.isFinite(v) || v < 1) return null + return Math.min(v, 50_000) +} + +/** Vim `j` / `k` — return new caret index preserving column when possible. */ +export function moveVertPos(text: string, pos: number, delta: -1 | 1): number { + const p = Math.min(Math.max(0, pos), text.length) + const before = text.slice(0, p) + const lineStart = before.lastIndexOf('\n') + 1 + const col = p - lineStart + const lines = text.split('\n') + const lineIdx = before.split('\n').length - 1 + const targetLine = Math.max(0, Math.min(lines.length - 1, lineIdx + delta)) + const targetText = lines[targetLine] ?? '' + const newCol = Math.min(col, targetText.length) + let out = 0 + for (let i = 0; i < targetLine; i++) out += lines[i]!.length + 1 + return out + newCol +} + +/** Vim `h` / `l` — horizontal caret motion with repeat count. */ +export function moveHorizPos(text: string, pos: number, delta: -1 | 1, steps = 1): number { + const max = text.length + let p = Math.min(Math.max(0, pos), max) + const n = Math.max(1, steps) + for (let i = 0; i < n; i++) { + p = delta < 0 ? Math.max(0, p - 1) : Math.min(max, p + 1) + } + return p +} + +/** `$` motion — last character on the current line (not past trailing newline). */ +export function lineEndCaretPos(text: string, pos: number): number { + const { start, end } = lineBounds(text, pos) + let p = end - 1 + if (p >= start && text[p] === '\n') p-- + return Math.max(start, p) +} + +export function firstNonBlankOnLine(text: string, pos: number): number { + const { start, end } = lineBounds(text, pos) + let p = start + while (p < end && /\s/.test(text[p]!)) p++ + return p < end ? p : start +} + +export function isWordChar(ch: string): boolean { + return /[A-Za-z0-9_]/.test(ch) +} + +/** Next word start — vim-like `w` on [A-Za-z0-9_] tokens */ +export function wordForwardPos(text: string, pos: number): number { + let p = Math.min(pos, text.length) + while (p < text.length && !isWordChar(text[p]!)) p++ + while (p < text.length && isWordChar(text[p]!)) p++ + return p +} + +/** Previous word start — vim-like `b` */ +export function wordBackPos(text: string, pos: number): number { + let p = Math.min(pos, text.length) + if (p > 0) p-- + while (p > 0 && !isWordChar(text[p]!)) p-- + while (p > 0 && isWordChar(text[p - 1]!)) p-- + return p +} + +/** End of next word — vim-like `e` */ +export function wordEndForwardPos(text: string, pos: number): number { + if (text.length === 0) return 0 + let p = Math.min(Math.max(0, pos), text.length - 1) + if (!isWordChar(text[p]!)) { + while (p < text.length && !isWordChar(text[p]!)) p++ + if (p >= text.length) return text.length - 1 + } + while (p < text.length - 1 && isWordChar(text[p + 1]!)) p++ + return p +} + +export function reverseFindKind(kind: 'f' | 'F' | 't' | 'T'): 'f' | 'F' | 't' | 'T' { + if (kind === 'f') return 'F' + if (kind === 'F') return 'f' + if (kind === 't') return 'T' + return 't' +} + +export function findNextOnLine( + text: string, + kind: 'f' | 'F' | 't' | 'T', + ch: string, + fromPos: number, +): number | null { + const { start, end } = lineBounds(text, fromPos) + const line = text.slice(start, end) + const rel = fromPos - start + if (kind === 'f' || kind === 't') { + const slice = line.slice(rel + 1) + const j = slice.indexOf(ch) + if (j < 0) return null + const hit = start + rel + 1 + j + return kind === 'f' ? hit : hit - 1 + } + const before = line.slice(0, rel) + let j = -1 + for (let bi = before.length - 1; bi >= 0; bi--) { + if (before[bi] === ch) { + j = bi + break + } + } + if (j < 0) return null + const hit = start + j + return kind === 'F' ? hit : hit + 1 +} + +/** Repeat `f`/`F`/`t`/`T` motion `times` from `fromPos`; null if any step fails. */ +export function repeatFindPos( + text: string, + times: number, + kind: 'f' | 'F' | 't' | 'T', + ch: string, + fromPos: number, +): number | null { + const n = Math.max(1, times) + let p = Math.min(Math.max(0, fromPos), text.length) + for (let i = 0; i < n; i++) { + const np = findNextOnLine(text, kind, ch, p) + if (np == null) return null + p = np + } + return p +} + +/** Repeat vertical motion `steps` times. */ +export function moveVertRepeat(text: string, pos: number, delta: -1 | 1, steps: number): number { + let p = Math.min(Math.max(0, pos), text.length) + const n = Math.max(1, steps) + for (let i = 0; i < n; i++) p = moveVertPos(text, p, delta) + return p +} + +export function wordForwardRepeat(text: string, pos: number, steps: number): number { + let p = Math.min(Math.max(0, pos), text.length) + for (let i = 0; i < Math.max(1, steps); i++) p = wordForwardPos(text, p) + return p +} + +export function wordBackRepeat(text: string, pos: number, steps: number): number { + let p = Math.min(Math.max(0, pos), text.length) + for (let i = 0; i < Math.max(1, steps); i++) p = wordBackPos(text, p) + return p +} + +export function wordEndForwardRepeat(text: string, pos: number, steps: number): number { + let p = Math.min(Math.max(0, pos), text.length) + for (let i = 0; i < Math.max(1, steps); i++) p = wordEndForwardPos(text, p) + return p +} + +/** `A` — caret position at end of current line. */ +export function appendLineEndPos(text: string, pos: number): number { + const p = Math.min(Math.max(0, pos), text.length) + const rest = text.slice(p) + const nl = rest.indexOf('\n') + return nl === -1 ? text.length : p + nl +} diff --git a/src/editor-vim-ops.ts b/src/editor-vim-ops.ts index 79bec1d..5bc5f17 100644 --- a/src/editor-vim-ops.ts +++ b/src/editor-vim-ops.ts @@ -1,456 +1,9 @@ /** - * Pure text/cursor helpers for the modal editor (vim-like motions). + * Barrel re-export for editor vim helpers. + * + * - `editor-vim-motions.ts` — caret positions, no buffer mutation + * - `editor-vim-edits.ts` — `{ text, pos }` buffer mutations */ -export function lineCountTotal(text: string): number { - if (!text) return 1 - return Math.max(1, text.split('\n').length) -} - -export function getLineCol(text: string, pos: number): { line: number; col: number } { - const p = Math.min(Math.max(0, pos), text.length) - const pref = text.slice(0, p) - const line = pref.split('\n').length - const li = pref.lastIndexOf('\n') - const col = p - (li + 1) + 1 - return { line, col } -} - -export function lineBounds(text: string, pos: number): { start: number; end: number } { - const lineStart = text.lastIndexOf('\n', pos - 1) + 1 - let lineEnd = text.indexOf('\n', pos) - if (lineEnd === -1) lineEnd = text.length - return { start: lineStart, end: lineEnd } -} - -export function gotoLinePos(text: string, oneBased: number): number { - const lines = text.split('\n') - const maxL = Math.max(1, lines.length) - const n = Math.max(1, Math.min(oneBased, maxL)) - let pos = 0 - for (let i = 0; i < n - 1; i++) pos += lines[i]!.length + 1 - return pos -} - -export function consumeCountDigits(digits: string, defaultN = 1): number { - if (!digits) return Math.max(1, defaultN) - const v = parseInt(digits, 10) - if (!Number.isFinite(v) || v < 1) return Math.max(1, defaultN) - return Math.min(v, 50_000) -} - -export function consumeOptionalNat(digits: string): number | null { - if (!digits) return null - const v = parseInt(digits, 10) - if (!Number.isFinite(v) || v < 1) return null - return Math.min(v, 50_000) -} - -/** Vim `j` / `k` — return new caret index preserving column when possible. */ -export function moveVertPos(text: string, pos: number, delta: -1 | 1): number { - const p = Math.min(Math.max(0, pos), text.length) - const before = text.slice(0, p) - const lineStart = before.lastIndexOf('\n') + 1 - const col = p - lineStart - const lines = text.split('\n') - const lineIdx = before.split('\n').length - 1 - const targetLine = Math.max(0, Math.min(lines.length - 1, lineIdx + delta)) - const targetText = lines[targetLine] ?? '' - const newCol = Math.min(col, targetText.length) - let out = 0 - for (let i = 0; i < targetLine; i++) out += lines[i]!.length + 1 - return out + newCol -} - -/** Vim `h` / `l` — horizontal caret motion with repeat count. */ -export function moveHorizPos(text: string, pos: number, delta: -1 | 1, steps = 1): number { - const max = text.length - let p = Math.min(Math.max(0, pos), max) - const n = Math.max(1, steps) - for (let i = 0; i < n; i++) { - p = delta < 0 ? Math.max(0, p - 1) : Math.min(max, p + 1) - } - return p -} - -/** `$` motion — last character on the current line (not past trailing newline). */ -export function lineEndCaretPos(text: string, pos: number): number { - const { start, end } = lineBounds(text, pos) - let p = end - 1 - if (p >= start && text[p] === '\n') p-- - return Math.max(start, p) -} - -export function firstNonBlankOnLine(text: string, pos: number): number { - const { start, end } = lineBounds(text, pos) - let p = start - while (p < end && /\s/.test(text[p]!)) p++ - return p < end ? p : start -} - -export function isWordChar(ch: string): boolean { - return /[A-Za-z0-9_]/.test(ch) -} - -/** Next word start — vim-like `w` on [A-Za-z0-9_] tokens */ -export function wordForwardPos(text: string, pos: number): number { - let p = Math.min(pos, text.length) - while (p < text.length && !isWordChar(text[p]!)) p++ - while (p < text.length && isWordChar(text[p]!)) p++ - return p -} - -/** Previous word start — vim-like `b` */ -export function wordBackPos(text: string, pos: number): number { - let p = Math.min(pos, text.length) - if (p > 0) p-- - while (p > 0 && !isWordChar(text[p]!)) p-- - while (p > 0 && isWordChar(text[p - 1]!)) p-- - return p -} - -/** End of next word — vim-like `e` */ -export function wordEndForwardPos(text: string, pos: number): number { - if (text.length === 0) return 0 - let p = Math.min(Math.max(0, pos), text.length - 1) - if (!isWordChar(text[p]!)) { - while (p < text.length && !isWordChar(text[p]!)) p++ - if (p >= text.length) return text.length - 1 - } - while (p < text.length - 1 && isWordChar(text[p + 1]!)) p++ - return p -} - -export function reverseFindKind(kind: 'f' | 'F' | 't' | 'T'): 'f' | 'F' | 't' | 'T' { - if (kind === 'f') return 'F' - if (kind === 'F') return 'f' - if (kind === 't') return 'T' - return 't' -} - -export function findNextOnLine( - text: string, - kind: 'f' | 'F' | 't' | 'T', - ch: string, - fromPos: number, -): number | null { - const { start, end } = lineBounds(text, fromPos) - const line = text.slice(start, end) - const rel = fromPos - start - if (kind === 'f' || kind === 't') { - const slice = line.slice(rel + 1) - const j = slice.indexOf(ch) - if (j < 0) return null - const hit = start + rel + 1 + j - return kind === 'f' ? hit : hit - 1 - } - const before = line.slice(0, rel) - let j = -1 - for (let bi = before.length - 1; bi >= 0; bi--) { - if (before[bi] === ch) { - j = bi - break - } - } - if (j < 0) return null - const hit = start + j - return kind === 'F' ? hit : hit + 1 -} - -/** Byte span covering `nLines` lines starting at `startLineIdx` (0-based). */ -export function lineBlockSpan( - text: string, - startLineIdx: number, - nLines: number, -): { start: number; end: number; lineCount: number } | null { - const lines = text.split('\n') - if (!lines.length || startLineIdx < 0 || startLineIdx >= lines.length) return null - const lineCount = Math.max(1, Math.min(nLines, lines.length - startLineIdx)) - let start = 0 - for (let i = 0; i < startLineIdx; i++) start += lines[i]!.length + 1 - let end = start - for (let i = startLineIdx; i < startLineIdx + lineCount; i++) { - end += lines[i]!.length - if (i < lines.length - 1) end += 1 - } - return { start, end, lineCount } -} - -export function deleteLineBlockText( - text: string, - curLineOneBased: number, - nLines: number, -): { text: string; pos: number } | null { - const span = lineBlockSpan(text, curLineOneBased - 1, nLines) - if (!span) return null - const next = text.slice(0, span.start) + text.slice(span.end) - return { text: next, pos: Math.min(span.start, next.length) } -} - -export function yankLineBlockText( - text: string, - curLineOneBased: number, - nLines: number, -): { yank: string; lineCount: number } | null { - const span = lineBlockSpan(text, curLineOneBased - 1, nLines) - if (!span) return null - let yank = text.slice(span.start, span.end) - if (!yank.endsWith('\n')) yank += '\n' - return { yank, lineCount: span.lineCount } -} - -export function deleteThroughEOLText(text: string, pos: number): { text: string; pos: number } { - const p = Math.min(Math.max(0, pos), text.length) - const { end } = lineBounds(text, p) - const next = text.slice(0, p) + text.slice(end) - return { text: next, pos: p } -} - -/** Minimal vi `J` — join `span` consecutive lines without extra spacing. */ -export function joinLinesText( - text: string, - curLineOneBased: number, - span: number, -): { text: string; pos: number } | null { - const lines = text.split('\n') - const cur = curLineOneBased - 1 - if (cur < 0 || cur >= lines.length - 1) return null - const maxSpan = Math.min(Math.max(2, span), lines.length - cur) - const merged = lines.slice(cur, cur + maxSpan).join('') - const block = lineBlockSpan(text, cur, maxSpan) - if (!block) return null - const next = text.slice(0, block.start) + merged + text.slice(block.end) - return { text: next, pos: Math.min(block.start + merged.length, next.length) } -} - -export function applyReplaceRunsText( - text: string, - pos: number, - n: number, - ch: string, -): { text: string; pos: number } | null { - const p = Math.min(Math.max(0, pos), text.length) - const take = Math.min(n, Math.max(0, text.length - p)) - if (take <= 0) return null - const rep = ch.repeat(take) - const next = text.slice(0, p) + rep + text.slice(p + take) - const c = Math.max(p, Math.min(p + take - 1, next.length - 1)) - return { text: next, pos: c } -} - -/** `p` paste after current line; `P` paste before current line. */ -export function pasteYankText( - text: string, - pos: number, - yank: string, - afterLine: boolean, -): { text: string; pos: number } | null { - if (!yank) return null - const { start, end } = lineBounds(text, pos) - const ins = afterLine - ? end < text.length && text[end] === '\n' - ? end + 1 - : text.length - : start - const next = text.slice(0, ins) + yank + text.slice(ins) - return { text: next, pos: ins + yank.length - 1 } -} - -/** Default `>>` / `<<` shift width (two spaces). */ -export const EDITOR_INDENT = ' ' - -function leadingUnindentWidth(line: string): number { - if (line.startsWith(' ')) return 2 - if (line.startsWith('\t')) return 1 - if (line.startsWith(' ')) return 1 - return 0 -} - -/** `>>` — indent `nLines` from caret line downward. */ -export function indentLinesText( - text: string, - pos: number, - nLines: number, - indent = EDITOR_INDENT, -): { text: string; pos: number } | null { - const p0 = Math.min(Math.max(0, pos), text.length) - const li = text.slice(0, p0).split('\n').length - 1 - const lines = text.split('\n') - if (li < 0 || li >= lines.length) return null - const take = Math.max(1, Math.min(nLines, lines.length - li)) - for (let j = 0; j < take; j++) lines[li + j] = indent + (lines[li + j] ?? '') - const next = lines.join('\n') - const curLineIdx = text.slice(0, p0).split('\n').length - 1 - let newP = p0 - if (curLineIdx >= li && curLineIdx < li + take) newP += indent.length - return { text: next, pos: newP } -} - -/** `<<` — unindent `nLines` from caret line downward. */ -export function unindentLinesText( - text: string, - pos: number, - nLines: number, -): { text: string; pos: number } | null { - const p0 = Math.min(Math.max(0, pos), text.length) - const li = text.slice(0, p0).split('\n').length - 1 - const lines = text.split('\n') - if (li < 0 || li >= lines.length) return null - const take = Math.max(1, Math.min(nLines, lines.length - li)) - const curLineIdx = text.slice(0, p0).split('\n').length - 1 - let removedBefore = 0 - for (let j = 0; j < take; j++) { - const idx = li + j - const s = lines[idx] ?? '' - const cut = leadingUnindentWidth(s) - if (cut && idx === curLineIdx) { - const lineStart = text.lastIndexOf('\n', p0 - 1) + 1 - const col = p0 - lineStart - removedBefore = Math.min(cut, col) - } - lines[idx] = s.slice(cut) - } - const next = lines.join('\n') - return { text: next, pos: Math.max(0, p0 - removedBefore) } -} - -/** `~` — toggle case on next `n` non-newline characters. */ -export function toggleCaseRunText( - text: string, - pos: number, - n: number, -): { text: string; pos: number } | null { - let p = Math.min(Math.max(0, pos), text.length) - let buf = text - let toggled = 0 - while (toggled < n && p < buf.length) { - const ch = buf[p]! - if (ch === '\n') { - p++ - continue - } - const repl = ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase() - buf = buf.slice(0, p) + repl + buf.slice(p + 1) - p++ - toggled++ - } - if (buf === text) return null - return { text: buf, pos: Math.min(p, buf.length) } -} - -/** `s` — delete `n` characters under cursor (enter insert in caller). */ -export function substituteCharsText( - text: string, - pos: number, - n: number, -): { text: string; pos: number } | null { - const take = Math.max(1, n) - const p = Math.min(Math.max(0, pos), text.length) - const del = Math.min(take, Math.max(0, text.length - p)) - if (del <= 0) return null - const next = text.slice(0, p) + text.slice(p + del) - return { text: next, pos: p } -} - -/** `x` — delete `n` characters forward from cursor. */ -export function deleteCharForwardText( - text: string, - pos: number, - n: number, -): { text: string; pos: number } | null { - const p = Math.min(Math.max(0, pos), text.length) - const kill = Math.min(Math.max(1, n), Math.max(0, text.length - p)) - if (!kill) return null - const next = text.slice(0, p) + text.slice(p + kill) - return { text: next, pos: p } -} - -/** `X` — delete `n` characters backward from cursor. */ -export function deleteCharBackwardText( - text: string, - pos: number, - n: number, -): { text: string; pos: number } | null { - const p = Math.min(Math.max(0, pos), text.length) - const chop = Math.min(Math.max(1, n), p) - if (!chop) return null - const next = text.slice(0, p - chop) + text.slice(p) - return { text: next, pos: p - chop } -} - -/** `Y` — yank from cursor through end of line (no trailing newline). */ -export function yankToEOLText(text: string, pos: number): string { - const p = Math.min(Math.max(0, pos), text.length) - const { end } = lineBounds(text, p) - return text.slice(p, end) -} - -/** `A` — caret position at end of current line. */ -export function appendLineEndPos(text: string, pos: number): number { - const p = Math.min(Math.max(0, pos), text.length) - const rest = text.slice(p) - const nl = rest.indexOf('\n') - return nl === -1 ? text.length : p + nl -} - -/** `o` — open new line below current line. */ -export function openLineBelowText(text: string, pos: number): { text: string; pos: number } { - const p = Math.min(Math.max(0, pos), text.length) - const nl = text.indexOf('\n', p) - const insAt = nl === -1 ? text.length : nl - const next = text.slice(0, insAt) + '\n' + text.slice(insAt) - return { text: next, pos: insAt + 1 } -} - -/** `O` — open new line above current line. */ -export function openLineAboveText(text: string, pos: number): { text: string; pos: number } { - const p = Math.min(Math.max(0, pos), text.length) - const lineStart = text.lastIndexOf('\n', p - 1) + 1 - const next = text.slice(0, lineStart) + '\n' + text.slice(lineStart) - return { text: next, pos: lineStart } -} - -/** Repeat `f`/`F`/`t`/`T` motion `times` from `fromPos`; null if any step fails. */ -export function repeatFindPos( - text: string, - times: number, - kind: 'f' | 'F' | 't' | 'T', - ch: string, - fromPos: number, -): number | null { - const n = Math.max(1, times) - let p = Math.min(Math.max(0, fromPos), text.length) - for (let i = 0; i < n; i++) { - const np = findNextOnLine(text, kind, ch, p) - if (np == null) return null - p = np - } - return p -} - -/** Repeat vertical motion `steps` times. */ -export function moveVertRepeat(text: string, pos: number, delta: -1 | 1, steps: number): number { - let p = Math.min(Math.max(0, pos), text.length) - const n = Math.max(1, steps) - for (let i = 0; i < n; i++) p = moveVertPos(text, p, delta) - return p -} - -/** Repeat word motion `steps` times. */ -export function wordForwardRepeat(text: string, pos: number, steps: number): number { - let p = Math.min(Math.max(0, pos), text.length) - for (let i = 0; i < Math.max(1, steps); i++) p = wordForwardPos(text, p) - return p -} - -export function wordBackRepeat(text: string, pos: number, steps: number): number { - let p = Math.min(Math.max(0, pos), text.length) - for (let i = 0; i < Math.max(1, steps); i++) p = wordBackPos(text, p) - return p -} - -export function wordEndForwardRepeat(text: string, pos: number, steps: number): number { - let p = Math.min(Math.max(0, pos), text.length) - for (let i = 0; i < Math.max(1, steps); i++) p = wordEndForwardPos(text, p) - return p -} +export * from './editor-vim-motions' +export * from './editor-vim-edits' diff --git a/src/editor-window.ts b/src/editor-window.ts index 24d914c..7fb4ca1 100644 --- a/src/editor-window.ts +++ b/src/editor-window.ts @@ -1,39 +1,30 @@ /** Modal editor over the fake VFS (normal / insert / ex); not the terminal one-line vim widget. */ import { parseEditorExCommand } from './editor-ex-commands' +import { + applyBufferEditToState, + applyStateToTextarea, + bufferStateFromTextarea, + type BufferEditResult, +} from './editor-buffer' +import { dispatchEditorNormalKey } from './editor-normal-handlers' import { insertModeKeyAction, tryAppendCountDigit } from './editor-vim-keys' import { applyReplaceRunsText, - appendLineEndPos, consumeCountDigits, consumeOptionalNat, - deleteCharBackwardText, - deleteCharForwardText, deleteLineBlockText, deleteThroughEOLText, - firstNonBlankOnLine, getLineCol, gotoLinePos, indentLinesText, joinLinesText, - lineBounds, lineCountTotal, - lineEndCaretPos, - moveHorizPos, - moveVertRepeat, - openLineAboveText, - openLineBelowText, pasteYankText, repeatFindPos, reverseFindKind, - substituteCharsText, - toggleCaseRunText, unindentLinesText, - wordBackRepeat, - wordEndForwardRepeat, - wordForwardRepeat, yankLineBlockText, - yankToEOLText, } from './editor-vim-ops' import { editorPathsEqual, editorWindowTitle } from './editor-window-meta' import { vfsFormatPath, vfsNormalize, vfsReadRaw, vfsWrite } from './os-fs' @@ -299,19 +290,15 @@ export class EditorWindow { this.gotoLine(this.lineCountTotal()) } - private firstNonBlankOnLine(pos: number): number { - return firstNonBlankOnLine(this.textarea.value, pos) - } - /** Apply a pure buffer edit result to the textarea + undo stack. */ private applyBufferEdit( - result: { text: string; pos: number } | null | undefined, + result: BufferEditResult | null | undefined, opts?: { enterInsert?: boolean }, ): boolean { - if (!result) return false - this.textarea.value = result.text - this.textarea.setSelectionRange(result.pos, result.pos) - this.dirty = result.text !== this.savedText + const state = bufferStateFromTextarea(this.textarea, this.savedText, this.dirty) + if (!applyBufferEditToState(state, result)) return false + applyStateToTextarea(this.textarea, state) + this.dirty = state.dirty this.recordAfterMutation() this.syncTitle() if (opts?.enterInsert) { @@ -912,35 +899,6 @@ export class EditorWindow { return } - // `:` prefix already cleared count - const pasteAfter = (): void => { - const result = pasteYankText(this.textarea.value, cur(), this.yankRegister, true) - if (!result) return - this.textarea.value = result.text - setCur(result.pos) - } - - const pasteBeforeLine = (): void => { - const result = pasteYankText(this.textarea.value, cur(), this.yankRegister, false) - if (!result) return - this.textarea.value = result.text - setCur(result.pos) - } - - if (k === 'p' || k === 'P') { - e.preventDefault() - if (!this.yankRegister) return - const times = this.consumeCount(1) - for (let i = 0; i < times; i++) { - if (k === 'p') pasteAfter() - else pasteBeforeLine() - } - this.dirty = this.textarea.value !== this.savedText - this.recordAfterMutation() - this.syncTitle() - return - } - if (k === '>') { e.preventDefault() this.clearPendingDy() @@ -966,214 +924,71 @@ export class EditorWindow { return } - if (k === '0') { - e.preventDefault() - this.consumeCount(1) - const { start } = this.lineBounds(cur()) - setCur(start) - return - } - - if (k === '^') { - e.preventDefault() - this.consumeCount(1) - setCur(this.firstNonBlankOnLine(cur())) - return - } - - if (k === '$') { - e.preventDefault() - this.consumeCount(1) - setCur(lineEndCaretPos(this.textarea.value, cur())) - return - } - - if (k === 'G') { - e.preventDefault() - this.clearPendingDy() - this.gArm = false - const n = this.consumeOptionalNat() - if (n == null) this.gotoLastLine() - else this.gotoLine(n) - return - } - - if (k === 'D') { - e.preventDefault() - this.consumeCount(1) - this.deleteThroughEOL() - return - } - - if (k === 'C') { - e.preventDefault() - this.consumeCount(1) - this.applyBufferEdit( - deleteThroughEOLText(this.textarea.value, cur()), - { enterInsert: true }, - ) - return - } - - if (k === 's') { - e.preventDefault() - this.applyBufferEdit( - substituteCharsText(this.textarea.value, cur(), this.consumeCount(1)), - { enterInsert: true }, - ) - return - } - - if (k === '~') { - e.preventDefault() - this.applyBufferEdit( - toggleCaseRunText(this.textarea.value, cur(), this.consumeCount(1)), - ) - return - } - - if (k === 'Y') { - e.preventDefault() - this.consumeCount(1) - this.yankRegister = yankToEOLText(this.textarea.value, cur()) - this.flashStatus('Yanked to end of line', false) - return - } - - if (k === 'J') { - e.preventDefault() - this.joinBelow() - return - } - - if (k === 'r') { - e.preventDefault() - const nRuns = this.consumeCount(1) - this.replacePending = { nRuns } - this.refreshModeMeta() - return - } - - if (k === 'X') { - e.preventDefault() - this.applyBufferEdit( - deleteCharBackwardText(this.textarea.value, cur(), this.consumeCount(1)), - ) - return - } - - if (k === 'w') { - e.preventDefault() - setCur(wordForwardRepeat(this.textarea.value, cur(), this.consumeCount(1))) - return - } - if (k === 'b') { - e.preventDefault() - setCur(wordBackRepeat(this.textarea.value, cur(), this.consumeCount(1))) - return - } - - if (k === 'e') { - e.preventDefault() - setCur(wordEndForwardRepeat(this.textarea.value, cur(), this.consumeCount(1))) - return - } - - if (k === 'i') { - e.preventDefault() - this.consumeCount(1) - this.mode = 'insert' - this.syncStatus() - this.syncEditorChrome() - return - } - if (k === 'a') { - e.preventDefault() - this.consumeCount(1) - this.mode = 'insert' - setCur(cur() + 1) - this.syncStatus() - this.syncEditorChrome() - return - } - if (k === 'A') { - e.preventDefault() - this.consumeCount(1) - this.mode = 'insert' - setCur(appendLineEndPos(this.textarea.value, cur())) - this.syncStatus() - this.syncEditorChrome() - return - } - if (k === 'I') { - e.preventDefault() - this.consumeCount(1) - this.mode = 'insert' - setCur(this.firstNonBlankOnLine(cur())) - this.syncStatus() - this.syncEditorChrome() - return - } - if (k === 'o') { - e.preventDefault() - this.consumeCount(1) - this.applyBufferEdit(openLineBelowText(this.textarea.value, cur()), { enterInsert: true }) - return - } - if (k === 'O') { - e.preventDefault() - this.consumeCount(1) - this.applyBufferEdit(openLineAboveText(this.textarea.value, cur()), { enterInsert: true }) - return - } - - if (k === 'h') { - e.preventDefault() - setCur(moveHorizPos(this.textarea.value, cur(), -1, this.consumeCount(1))) - return - } - if (k === 'l') { - e.preventDefault() - setCur(moveHorizPos(this.textarea.value, cur(), 1, this.consumeCount(1))) - return - } - if (k === 'j') { - e.preventDefault() - setCur(moveVertRepeat(this.textarea.value, cur(), 1, this.consumeCount(1))) - return - } - if (k === 'k') { - e.preventDefault() - setCur(moveVertRepeat(this.textarea.value, cur(), -1, this.consumeCount(1))) - return - } - - if (k === 'x') { - e.preventDefault() - this.applyBufferEdit( - deleteCharForwardText(this.textarea.value, cur(), this.consumeCount(1)), - ) - return - } - - if (k === 'u') { - e.preventDefault() - if (this.snapPtr <= 0) return - this.snapPtr-- - this.textarea.value = this.snapshots[this.snapPtr]! - this.dirty = this.textarea.value !== this.savedText - this.syncTitle() - this.refreshModeMeta() + const self = this + if ( + dispatchEditorNormalKey({ + key: k, + prevent: () => e.preventDefault(), + cur, + setCur, + text: () => self.textarea.value, + consumeCount: (defaultN?: number) => self.consumeCount(defaultN), + consumeOptionalNat: () => self.consumeOptionalNat(), + applyEdit: (result, opts) => self.applyBufferEdit(result, opts), + enterInsert: () => { + self.mode = 'insert' + self.syncStatus() + self.syncEditorChrome() + }, + enterInsertAt: pos => { + self.mode = 'insert' + setCur(pos) + self.syncStatus() + self.syncEditorChrome() + }, + gotoLine: n => self.gotoLine(n), + gotoLastLine: () => self.gotoLastLine(), + deleteThroughEOL: () => self.deleteThroughEOL(), + joinBelow: () => self.joinBelow(), + get yankRegister() { + return self.yankRegister + }, + set yankRegister(v: string) { + self.yankRegister = v + }, + flash: msg => self.flashStatus(msg, false), + armReplace: nRuns => { + self.replacePending = { nRuns } + self.refreshModeMeta() + }, + undo: () => { + if (self.snapPtr <= 0) return + self.snapPtr-- + self.textarea.value = self.snapshots[self.snapPtr]! + self.dirty = self.textarea.value !== self.savedText + self.syncTitle() + self.refreshModeMeta() + }, + paste: (times, afterLine) => { + if (!self.yankRegister) return + for (let i = 0; i < times; i++) { + const result = pasteYankText(self.textarea.value, cur(), self.yankRegister, afterLine) + if (!result) return + self.textarea.value = result.text + setCur(result.pos) + } + self.dirty = self.textarea.value !== self.savedText + self.recordAfterMutation() + self.syncTitle() + }, + }) + ) { return } swallowPrintable() } - private lineBounds(pos: number): { start: number; end: number } { - return lineBounds(this.textarea.value, pos) - } - private reverseFindKind(kind: 'f' | 'F' | 't' | 'T'): 'f' | 'F' | 't' | 'T' { return reverseFindKind(kind) }