diff --git a/README.md b/README.md index e0c2ba6..6229ce6 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** | **563** unit · **3** e2e smoke · CI on every `main` push | +| **Tests** | **583** 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 # 563 unit tests +npm test # 583 unit tests npm run build && npm run test:e2e ``` diff --git a/docs/API.md b/docs/API.md index e08f17f..14e3f4d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -253,13 +253,22 @@ class VimInput { Pure functions in `editor-vim-ops.ts` — safe to unit test without DOM: ```typescript -lineCountTotal(text: string): number -getLineCol(text: string, pos: number): { line: number; col: number } -moveVertPos(text: string, pos: number, delta: -1 | 1): number -moveHorizPos(text: string, pos: number, delta: -1 | 1, steps?: number): number +// Motions +moveVertPos / moveVertRepeat / moveHorizPos wordForwardPos / wordBackPos / wordEndForwardPos -findNextOnLine(text, kind, ch, fromPos): number | null -deleteLineBlockText / yankLineBlockText / joinLinesText / pasteYankText +wordForwardRepeat / wordBackRepeat / wordEndForwardRepeat +findNextOnLine / repeatFindPos / reverseFindKind +lineEndCaretPos / appendLineEndPos / firstNonBlankOnLine + +// Edits +indentLinesText / unindentLinesText // >> / << +toggleCaseRunText // ~ +substituteCharsText // s +deleteCharForwardText / deleteCharBackwardText // x / X +deleteLineBlockText / yankLineBlockText / yankToEOLText +deleteThroughEOLText // D / C +joinLinesText / openLineBelowText / openLineAboveText +applyReplaceRunsText / pasteYankText // ... ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d69cf27..7aebdbd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -228,7 +228,7 @@ Bump VFS key version in `os-fs.ts` to reset visitor filesystems. ## Testing -**563 tests** · **46 files** · Vitest in Node · Playwright e2e smoke. +**583 tests** · **46 files** · Vitest in Node · Playwright e2e smoke. ### By domain diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8493d30..0efceea 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 — 563 unit tests +npm test # Vitest — 583 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 8e43938..35ba633 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -100,7 +100,7 @@ Files persist in `localStorage` under `portfolio-vfs-v8-namefailed-home`. First | Stage | Tool | |-------|------| -| Unit | Vitest — **563 tests**, **46 files**, Node environment with DOM stubs where needed | +| Unit | Vitest — **583 tests**, **46 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 cedab9a..f223237 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 | **563** across **46** files (`npm test`) | +| Unit tests | **583** across **46** 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 c43a289..037e1a8 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` — **563** Vitest unit tests (46 files) +- `npm test` — **583** Vitest unit tests (46 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-vim-ops.test.ts b/src/editor-vim-ops.test.ts index ed32ed6..ce0b7f1 100644 --- a/src/editor-vim-ops.test.ts +++ b/src/editor-vim-ops.test.ts @@ -1,27 +1,40 @@ import { describe, it, expect } from 'vitest' import { applyReplaceRunsText, + appendLineEndPos, + consumeCountDigits, + deleteCharBackwardText, + deleteCharForwardText, deleteLineBlockText, findNextOnLine, + getLineCol, + gotoLinePos, + 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' -import { - consumeCountDigits, - getLineCol, - gotoLinePos, - lineBounds, - lineCountTotal, - moveVertPos, -} from './editor-vim-ops' describe('lineCountTotal', () => { it('returns 1 for empty buffer', () => { @@ -158,6 +171,117 @@ describe('pasteYankText', () => { }) }) +describe('indentLinesText', () => { + it('indents from caret line and shifts caret right', () => { + const result = indentLinesText(' foo\nbar', 2, 1) + expect(result).toEqual({ text: ' foo\nbar', pos: 4 }) + }) + + it('indents multiple lines from caret line', () => { + const result = indentLinesText('a\nb\nc', 2, 2) + expect(result?.text).toBe('a\n b\n c') + }) +}) + +describe('unindentLinesText', () => { + it('removes two-space indent and shifts caret left', () => { + const result = unindentLinesText(' hello', 4, 1) + expect(result).toEqual({ text: 'hello', pos: 2 }) + }) + + it('removes single space or tab', () => { + expect(unindentLinesText(' hello', 1, 1)?.text).toBe('hello') + expect(unindentLinesText('\tworld', 1, 1)?.text).toBe('world') + }) +}) + +describe('toggleCaseRunText', () => { + it('toggles case on next n characters', () => { + expect(toggleCaseRunText('hello', 0, 2)).toEqual({ text: 'HEllo', pos: 2 }) + }) + + it('skips newlines without counting toward n', () => { + expect(toggleCaseRunText('a\nb', 0, 2)).toEqual({ text: 'A\nB', pos: 3 }) + }) + + it('returns null when nothing toggled', () => { + expect(toggleCaseRunText('123', 0, 1)).toBeNull() + }) +}) + +describe('substituteCharsText', () => { + it('deletes n chars under cursor', () => { + expect(substituteCharsText('hello', 1, 2)).toEqual({ text: 'hlo', pos: 1 }) + }) +}) + +describe('deleteCharForwardText', () => { + it('deletes forward (x)', () => { + expect(deleteCharForwardText('abcd', 1, 2)).toEqual({ text: 'ad', pos: 1 }) + }) +}) + +describe('deleteCharBackwardText', () => { + it('deletes backward (X)', () => { + expect(deleteCharBackwardText('abcd', 2, 1)).toEqual({ text: 'acd', pos: 1 }) + }) +}) + +describe('yankToEOLText', () => { + it('yanks through line end without newline', () => { + expect(yankToEOLText('hello\nworld', 1)).toBe('ello') + }) +}) + +describe('appendLineEndPos', () => { + it('returns index of last char on line', () => { + expect(appendLineEndPos('hello\nworld', 0)).toBe(5) + expect(appendLineEndPos('hello', 0)).toBe(5) + }) +}) + +describe('openLineBelowText / openLineAboveText', () => { + it('opens line below (o)', () => { + expect(openLineBelowText('ab\ncd', 1)).toEqual({ text: 'ab\n\ncd', pos: 3 }) + }) + + it('opens line above (O)', () => { + expect(openLineAboveText('ab\ncd', 3)).toEqual({ text: 'ab\n\ncd', pos: 3 }) + }) +}) + +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 editing scenarios', () => { it('yank-delete-paste round trip restores buffer', () => { const original = 'line1\nline2\nline3' diff --git a/src/editor-vim-ops.ts b/src/editor-vim-ops.ts index 004d8ab..79bec1d 100644 --- a/src/editor-vim-ops.ts +++ b/src/editor-vim-ops.ts @@ -255,3 +255,202 @@ export function pasteYankText( 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 +} diff --git a/src/editor-window.ts b/src/editor-window.ts index 61e37b1..24d914c 100644 --- a/src/editor-window.ts +++ b/src/editor-window.ts @@ -4,26 +4,36 @@ import { parseEditorExCommand } from './editor-ex-commands' import { insertModeKeyAction, tryAppendCountDigit } from './editor-vim-keys' import { applyReplaceRunsText, + appendLineEndPos, consumeCountDigits, consumeOptionalNat, + deleteCharBackwardText, + deleteCharForwardText, deleteLineBlockText, deleteThroughEOLText, - findNextOnLine, firstNonBlankOnLine, getLineCol, gotoLinePos, + indentLinesText, joinLinesText, lineBounds, lineCountTotal, lineEndCaretPos, moveHorizPos, - moveVertPos, + moveVertRepeat, + openLineAboveText, + openLineBelowText, pasteYankText, + repeatFindPos, reverseFindKind, - wordBackPos, - wordEndForwardPos, - wordForwardPos, + 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' @@ -293,16 +303,30 @@ export class EditorWindow { return firstNonBlankOnLine(this.textarea.value, pos) } - private deleteLineBlock(nLines: number): void { - const line = this.getLineCol().line - const result = deleteLineBlockText(this.textarea.value, line, nLines) - if (!result) return + /** Apply a pure buffer edit result to the textarea + undo stack. */ + private applyBufferEdit( + result: { text: string; pos: number } | 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 this.recordAfterMutation() this.syncTitle() + if (opts?.enterInsert) { + this.mode = 'insert' + this.syncStatus() + this.syncEditorChrome() + } this.refreshModeMeta() + return true + } + + private deleteLineBlock(nLines: number): void { + this.applyBufferEdit( + deleteLineBlockText(this.textarea.value, this.getLineCol().line, nLines), + ) } private yankLineBlock(nLines: number): void { @@ -313,38 +337,24 @@ export class EditorWindow { } private deleteThroughEOL(): void { - const result = deleteThroughEOLText(this.textarea.value, this.textarea.selectionStart) - this.textarea.value = result.text - this.textarea.setSelectionRange(result.pos, result.pos) - this.dirty = result.text !== this.savedText - this.recordAfterMutation() - this.syncTitle() - this.refreshModeMeta() + this.applyBufferEdit( + deleteThroughEOLText(this.textarea.value, this.textarea.selectionStart), + ) } /** Join consecutive lines beginning at cursor line (minimal vi `J`): no extra spacing. */ private joinBelow(): void { const span = this.consumeCount(1) + 1 - const result = joinLinesText(this.textarea.value, this.getLineCol().line, span) - if (!result) return - this.textarea.value = result.text - this.textarea.setSelectionRange(result.pos, result.pos) - this.dirty = result.text !== this.savedText - this.recordAfterMutation() - this.syncTitle() - this.refreshModeMeta() + this.applyBufferEdit( + joinLinesText(this.textarea.value, this.getLineCol().line, span), + ) } /** `r` / `{count}r`: replace each of the next `n` glyphs with `ch` (minimal vi semantics). */ private applyReplaceRuns(n: number, ch: string): void { - const result = applyReplaceRunsText(this.textarea.value, this.textarea.selectionStart, n, ch) - if (!result) return - this.textarea.value = result.text - this.textarea.setSelectionRange(result.pos, result.pos) - this.dirty = result.text !== this.savedText - this.recordAfterMutation() - this.syncTitle() - this.refreshModeMeta() + this.applyBufferEdit( + applyReplaceRunsText(this.textarea.value, this.textarea.selectionStart, n, ch), + ) } /** Path matches the file currently loaded in this tile. */ @@ -998,29 +1008,34 @@ export class EditorWindow { if (k === 'C') { e.preventDefault() this.consumeCount(1) - this.changeThroughEOL() + this.applyBufferEdit( + deleteThroughEOLText(this.textarea.value, cur()), + { enterInsert: true }, + ) return } if (k === 's') { e.preventDefault() - this.substituteChars(this.consumeCount(1)) + this.applyBufferEdit( + substituteCharsText(this.textarea.value, cur(), this.consumeCount(1)), + { enterInsert: true }, + ) return } if (k === '~') { e.preventDefault() - this.toggleCaseRun(this.consumeCount(1)) + this.applyBufferEdit( + toggleCaseRunText(this.textarea.value, cur(), this.consumeCount(1)), + ) return } if (k === 'Y') { e.preventDefault() this.consumeCount(1) - const t = this.textarea.value - const p = cur() - const { end } = this.lineBounds(p) - this.yankRegister = t.slice(p, end) + this.yankRegister = yankToEOLText(this.textarea.value, cur()) this.flashStatus('Yanked to end of line', false) return } @@ -1041,46 +1056,26 @@ export class EditorWindow { if (k === 'X') { e.preventDefault() - const n = this.consumeCount(1) - let t = this.textarea.value - let p = cur() - const chop = Math.min(n, p) - if (!chop) return - t = t.slice(0, p - chop) + t.slice(p) - this.textarea.value = t - setCur(p - chop) - this.dirty = t !== this.savedText - this.recordAfterMutation() - this.syncTitle() + this.applyBufferEdit( + deleteCharBackwardText(this.textarea.value, cur(), this.consumeCount(1)), + ) return } if (k === 'w') { e.preventDefault() - const n = this.consumeCount(1) - let p = cur() - const t = this.textarea.value - for (let i = 0; i < n; i++) p = wordForwardPos(t, p) - setCur(p) + setCur(wordForwardRepeat(this.textarea.value, cur(), this.consumeCount(1))) return } if (k === 'b') { e.preventDefault() - const n = this.consumeCount(1) - let p = cur() - const t = this.textarea.value - for (let i = 0; i < n; i++) p = wordBackPos(t, p) - setCur(p) + setCur(wordBackRepeat(this.textarea.value, cur(), this.consumeCount(1))) return } if (k === 'e') { e.preventDefault() - const n = this.consumeCount(1) - let p = cur() - const t = this.textarea.value - for (let i = 0; i < n; i++) p = wordEndForwardPos(t, p) - setCur(p) + setCur(wordEndForwardRepeat(this.textarea.value, cur(), this.consumeCount(1))) return } @@ -1104,13 +1099,8 @@ export class EditorWindow { if (k === 'A') { e.preventDefault() this.consumeCount(1) - const t = this.textarea.value - const pos = cur() - const rest = t.slice(pos) - const nl = rest.indexOf('\n') - const end = nl === -1 ? t.length : pos + nl this.mode = 'insert' - setCur(end) + setCur(appendLineEndPos(this.textarea.value, cur())) this.syncStatus() this.syncEditorChrome() return @@ -1127,36 +1117,13 @@ export class EditorWindow { if (k === 'o') { e.preventDefault() this.consumeCount(1) - const t = this.textarea.value - const pos = cur() - const nl = t.indexOf('\n', pos) - const insAt = nl === -1 ? t.length : nl - const next = t.slice(0, insAt) + '\n' + t.slice(insAt) - this.textarea.value = next - setCur(insAt + 1) - this.dirty = this.textarea.value !== this.savedText - this.mode = 'insert' - this.recordAfterMutation() - this.syncTitle() - this.syncStatus() - this.syncEditorChrome() + this.applyBufferEdit(openLineBelowText(this.textarea.value, cur()), { enterInsert: true }) return } if (k === 'O') { e.preventDefault() this.consumeCount(1) - const t = this.textarea.value - const pos = cur() - const lineStart = t.lastIndexOf('\n', pos - 1) + 1 - const next = t.slice(0, lineStart) + '\n' + t.slice(lineStart) - this.textarea.value = next - setCur(lineStart) - this.dirty = this.textarea.value !== this.savedText - this.mode = 'insert' - this.recordAfterMutation() - this.syncTitle() - this.syncStatus() - this.syncEditorChrome() + this.applyBufferEdit(openLineAboveText(this.textarea.value, cur()), { enterInsert: true }) return } @@ -1172,37 +1139,20 @@ export class EditorWindow { } if (k === 'j') { e.preventDefault() - const n = this.consumeCount(1) - for (let i = 0; i < n; i++) { - const np = this.moveVert(1) - this.textarea.setSelectionRange(np, np) - } - this.refreshModeMeta() + setCur(moveVertRepeat(this.textarea.value, cur(), 1, this.consumeCount(1))) return } if (k === 'k') { e.preventDefault() - const n = this.consumeCount(1) - for (let i = 0; i < n; i++) { - const np = this.moveVert(-1) - this.textarea.setSelectionRange(np, np) - } - this.refreshModeMeta() + setCur(moveVertRepeat(this.textarea.value, cur(), -1, this.consumeCount(1))) return } if (k === 'x') { e.preventDefault() - const n = this.consumeCount(1) - const t = this.textarea.value - let p = cur() - const kill = Math.min(n, Math.max(0, t.length - p)) - if (!kill) return - this.textarea.value = t.slice(0, p) + t.slice(p + kill) - setCur(p) - this.dirty = this.textarea.value !== this.savedText - this.recordAfterMutation() - this.syncTitle() + this.applyBufferEdit( + deleteCharForwardText(this.textarea.value, cur(), this.consumeCount(1)), + ) return } @@ -1224,10 +1174,6 @@ export class EditorWindow { return lineBounds(this.textarea.value, pos) } - private findNextOnLine(kind: 'f' | 'F' | 't' | 'T', ch: string, fromPos: number): number | null { - return findNextOnLine(this.textarea.value, kind, ch, fromPos) - } - private reverseFindKind(kind: 'f' | 'F' | 't' | 'T'): 'f' | 'F' | 't' | 'T' { return reverseFindKind(kind) } @@ -1238,16 +1184,13 @@ export class EditorWindow { ch: string, remember: boolean, ): void { - let p = Math.min(Math.max(0, this.textarea.selectionStart), this.textarea.value.length) - for (let i = 0; i < times; i++) { - const np = this.findNextOnLine(kind, ch, p) - if (np == null) { - if (i === 0) this.flashStatus('f/F/t/T: not found on this line', true) - return - } - p = np + const p0 = Math.min(Math.max(0, this.textarea.selectionStart), this.textarea.value.length) + const np = repeatFindPos(this.textarea.value, times, kind, ch, p0) + if (np == null) { + this.flashStatus('f/F/t/T: not found on this line', true) + return } - this.textarea.setSelectionRange(p, p) + this.textarea.setSelectionRange(np, np) if (remember) this.lastFind = { kind, ch } this.refreshModeMeta() } @@ -1258,116 +1201,15 @@ export class EditorWindow { } private indentLines(nLines: number): void { - const t = this.textarea.value - const p0 = Math.min(Math.max(0, this.textarea.selectionStart), t.length) - const li = t.slice(0, p0).split('\n').length - 1 - const lines = t.split('\n') - const take = Math.max(1, Math.min(nLines, lines.length - li)) - for (let j = 0; j < take; j++) lines[li + j] = ' ' + (lines[li + j] ?? '') - const next = lines.join('\n') - const curLineIdx = t.slice(0, p0).split('\n').length - 1 - let newP = p0 - if (curLineIdx >= li && curLineIdx < li + take) newP += 2 - this.textarea.value = next - this.textarea.setSelectionRange(newP, newP) - this.dirty = next !== this.savedText - this.recordAfterMutation() - this.syncTitle() - this.refreshModeMeta() + this.applyBufferEdit( + indentLinesText(this.textarea.value, this.textarea.selectionStart, nLines), + ) } private unindentLines(nLines: number): void { - const t = this.textarea.value - const p0 = Math.min(Math.max(0, this.textarea.selectionStart), t.length) - const li = t.slice(0, p0).split('\n').length - 1 - const lines = t.split('\n') - const take = Math.max(1, Math.min(nLines, lines.length - li)) - const curLineIdx = t.slice(0, p0).split('\n').length - 1 - let removedBefore = 0 - for (let j = 0; j < take; j++) { - const idx = li + j - let s = lines[idx] ?? '' - let cut = 0 - if (s.startsWith(' ')) cut = 2 - else if (s.startsWith('\t')) cut = 1 - else if (s.startsWith(' ')) cut = 1 - if (cut && idx === curLineIdx) { - const lineStart = t.lastIndexOf('\n', p0 - 1) + 1 - const col = p0 - lineStart - removedBefore = Math.min(cut, col) - } - lines[idx] = s.slice(cut) - } - const next = lines.join('\n') - const newP = Math.max(0, p0 - removedBefore) - this.textarea.value = next - this.textarea.setSelectionRange(newP, newP) - this.dirty = next !== this.savedText - this.recordAfterMutation() - this.syncTitle() - this.refreshModeMeta() - } - - private toggleCaseRun(n: number): void { - const t = this.textarea.value - let p = Math.min(Math.max(0, this.textarea.selectionStart), t.length) - let buf = t - for (let i = 0; i < 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++ - i++ - } - if (buf === t) return - this.textarea.value = buf - const endPos = Math.min(p, buf.length) - this.textarea.setSelectionRange(endPos, endPos) - this.dirty = buf !== this.savedText - this.recordAfterMutation() - this.syncTitle() - this.refreshModeMeta() - } - - private changeThroughEOL(): void { - const t = this.textarea.value - const p = Math.min(Math.max(0, this.textarea.selectionStart), t.length) - const { end } = this.lineBounds(p) - const next = t.slice(0, p) + t.slice(end) - this.textarea.value = next - this.textarea.setSelectionRange(p, p) - this.dirty = next !== this.savedText - this.recordAfterMutation() - this.syncTitle() - this.mode = 'insert' - this.syncStatus() - this.syncEditorChrome() - this.refreshModeMeta() - } - - private substituteChars(n: number): void { - const take = Math.max(1, n) - const t = this.textarea.value - const p = Math.min(Math.max(0, this.textarea.selectionStart), t.length) - const del = Math.min(take, Math.max(0, t.length - p)) - const next = t.slice(0, p) + t.slice(p + del) - this.textarea.value = next - this.textarea.setSelectionRange(p, p) - this.dirty = next !== this.savedText - this.recordAfterMutation() - this.syncTitle() - this.mode = 'insert' - this.syncStatus() - this.syncEditorChrome() - this.refreshModeMeta() - } - - private moveVert(delta: -1 | 1): number { - return moveVertPos(this.textarea.value, this.textarea.selectionStart, delta) + this.applyBufferEdit( + unindentLinesText(this.textarea.value, this.textarea.selectionStart, nLines), + ) } setActive(active: boolean): void {