From 2f50358f758d0d1f5afb294ed373a34d60ce4dfc Mon Sep 17 00:00:00 2001 From: grey Date: Tue, 9 Jun 2026 05:32:38 -0500 Subject: [PATCH] Expand editor vim ops, maximize tests, trim dead splitter CSS Extract word/find/motion/text-mutation helpers and INSERT/NORMAL key chords. Add integration-style vim scenario tests and real desktop-wm-maximize unit tests. Remove unused .splitter-h rules from section-4.css. --- docs/ARCHITECTURE.md | 5 +- src/desktop-wm-maximize.test.ts | 148 +++++++++++++++++++- src/editor-vim-keys.test.ts | 31 +++++ src/editor-vim-keys.ts | 22 +++ src/editor-vim-ops.test.ts | 135 ++++++++++++++++++- src/editor-vim-ops.ts | 187 ++++++++++++++++++++++++++ src/editor-window.ts | 230 +++++++++----------------------- src/splitter.ts | 2 +- src/styles/section-4.css | 13 +- 9 files changed, 586 insertions(+), 187 deletions(-) create mode 100644 src/editor-vim-keys.test.ts create mode 100644 src/editor-vim-keys.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 042fc9d..c068c13 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,7 +34,8 @@ Personal portfolio site built as an in-browser fake desktop environment. TypeScr | `desktop-wm-sync.ts` | Shell `dataset.*` mirrors for CSS | | `desktop-ps-snapshot.ts` | Simulated `ps` rows for terminal MOTD | | `editor-ex-commands.ts` | Modal editor `:w` / `:q` / `:e` ex-mode parsing | -| `editor-vim-ops.ts` | Pure caret/line helpers for vim motions (`j`/`k`, `:N`, counts) | +| `editor-vim-ops.ts` | Pure caret/line/motion helpers for vim motions (`j`/`k`, `:N`, counts, dd/yy) | +| `editor-vim-keys.ts` | INSERT/NORMAL key-chord helpers (Esc, count prefix) | | `editor-window-meta.ts` | Editor path compare + title string helpers | | `terminal.ts` | xterm.js façade, scripted boot lines, Vim-style prompt, command dispatch | | `bootstrap-shell.ts` | Boot sequence orchestration | @@ -128,7 +129,7 @@ Sketches are seeded into `~/sketches/` in the VFS (`os-fs.ts`) at first visit. ## Testing -535 tests across 45 test files (plus Playwright smoke e2e). Tests co-located with source: `module.ts` → `module.test.ts`. +563 tests across 46 test files (plus Playwright smoke e2e). Tests co-located with source: `module.ts` → `module.test.ts`. | Test File | Coverage | |-----------|----------| diff --git a/src/desktop-wm-maximize.test.ts b/src/desktop-wm-maximize.test.ts index 4400e93..2120ea2 100644 --- a/src/desktop-wm-maximize.test.ts +++ b/src/desktop-wm-maximize.test.ts @@ -1,7 +1,149 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeAll } from 'vitest' +import { + toggleMaximizeContent, + toggleMaximizeFocused, + unmaximizeContent, + type WmMaximizeContext, +} from './desktop-wm-maximize' +import type { TiledWin } from './desktop-open-window' + +class FakeEl { + className = '' + dataset: Record = {} + readonly classList = { + add: (c: string) => { + const parts = new Set(this.className.split(/\s+/).filter(Boolean)) + parts.add(c) + this.className = [...parts].join(' ') + }, + remove: (c: string) => { + this.className = this.className + .split(/\s+/) + .filter(x => x && x !== c) + .join(' ') + }, + contains: (c: string) => this.className.split(/\s+/).includes(c), + } +} + +function mockWin(command: string): TiledWin & { el: FakeEl } { + const el = new FakeEl() + el.className = 'content-window' + return { + command, + el: el as unknown as HTMLElement, + isMaximized: () => el.classList.contains('maximized'), + } as TiledWin & { el: FakeEl } +} + +function makeCtx() { + let maximizedId: string | null = null + const panes = new FakeEl() + const desktop = new FakeEl() + const wins = new Map() + const syncDockVisibility = vi.fn() + const onAfterMaximizeLayout = vi.fn() + const attachVerticalSplitters = vi.fn() + const sync = vi.fn() + + const ctx: WmMaximizeContext = { + getMaximizedId: () => maximizedId, + setMaximizedId: id => { + maximizedId = id + }, + panes: panes as unknown as HTMLElement, + desktop: desktop as unknown as HTMLElement, + findOpenWindow: cmd => wins.get(cmd), + unmaximizeContent: win => unmaximizeContent(ctx, win), + syncDockVisibility, + onAfterMaximizeLayout, + attachVerticalSplitters, + sync, + } + + return { ctx, panes, desktop, wins, syncDockVisibility, onAfterMaximizeLayout, attachVerticalSplitters, sync } +} + +beforeAll(() => { + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { + fn(0) + return 0 + }) +}) + +describe('toggleMaximizeContent', () => { + it('maximizes a content window and sets shell dataset', () => { + const { ctx, panes, desktop, wins, syncDockVisibility, onAfterMaximizeLayout } = makeCtx() + const win = mockWin('resume') + wins.set('resume', win) + + toggleMaximizeContent(ctx, win) + + expect(win.el.classList.contains('maximized')).toBe(true) + expect(panes.classList.contains('max-content')).toBe(true) + expect(ctx.getMaximizedId()).toBe('resume') + expect(desktop.dataset.maximized).toBe('1') + expect(syncDockVisibility).toHaveBeenCalledOnce() + expect(onAfterMaximizeLayout).toHaveBeenCalledOnce() + }) + + it('unmaximizes when the window is already maximized', () => { + const { ctx, panes, wins, attachVerticalSplitters, sync } = makeCtx() + const win = mockWin('whoami') + win.el.classList.add('maximized') + panes.classList.add('max-content') + ctx.setMaximizedId('whoami') + wins.set('whoami', win) + + toggleMaximizeContent(ctx, win) + + expect(win.el.classList.contains('maximized')).toBe(false) + expect(panes.classList.contains('max-content')).toBe(false) + expect(ctx.getMaximizedId()).toBeNull() + expect(attachVerticalSplitters).toHaveBeenCalledOnce() + expect(sync).toHaveBeenCalledOnce() + }) + + it('demotes the previously maximized window when switching maximize target', () => { + const { ctx, wins } = makeCtx() + const a = mockWin('resume') + const b = mockWin('links') + wins.set('resume', a) + wins.set('links', b) + + toggleMaximizeContent(ctx, a) + toggleMaximizeContent(ctx, b) + + expect(a.el.classList.contains('maximized')).toBe(false) + expect(b.el.classList.contains('maximized')).toBe(true) + expect(ctx.getMaximizedId()).toBe('links') + }) +}) describe('toggleMaximizeFocused', () => { - it('is covered by desktop WM integration tests', () => { - expect(true).toBe(true) + it('no-ops when nothing is focused', () => { + const { ctx } = makeCtx() + toggleMaximizeFocused(ctx, null) + expect(ctx.getMaximizedId()).toBeNull() + }) + + it('maximizes the focused window command', () => { + const { ctx, wins } = makeCtx() + const win = mockWin('projects') + wins.set('projects', win) + + toggleMaximizeFocused(ctx, 'projects') + + expect(win.el.classList.contains('maximized')).toBe(true) + expect(ctx.getMaximizedId()).toBe('projects') + }) +}) + +describe('unmaximizeContent', () => { + it('ignores windows that are not maximized', () => { + const { ctx, sync } = makeCtx() + const win = mockWin('paint') + unmaximizeContent(ctx, win) + expect(sync).not.toHaveBeenCalled() }) }) diff --git a/src/editor-vim-keys.test.ts b/src/editor-vim-keys.test.ts new file mode 100644 index 0000000..8e62546 --- /dev/null +++ b/src/editor-vim-keys.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { insertModeKeyAction, tryAppendCountDigit } from './editor-vim-keys' + +describe('insertModeKeyAction', () => { + it('leaves insert on Escape', () => { + expect(insertModeKeyAction('Escape', { ctrlKey: false, metaKey: false })).toBe('leave-normal') + }) + + it('leaves insert on Ctrl+[', () => { + expect(insertModeKeyAction('[', { ctrlKey: true, metaKey: false })).toBe('leave-normal') + }) + + it('passes through ordinary keys', () => { + expect(insertModeKeyAction('a', { ctrlKey: false, metaKey: false })).toBe('pass') + }) +}) + +describe('tryAppendCountDigit', () => { + it('starts a count with 1-9', () => { + expect(tryAppendCountDigit('', '3')).toBe('3') + }) + + it('appends 0 only when digits already buffered', () => { + expect(tryAppendCountDigit('', '0')).toBeNull() + expect(tryAppendCountDigit('12', '0')).toBe('120') + }) + + it('caps at six digits', () => { + expect(tryAppendCountDigit('123456', '7')).toBe('123456') + }) +}) diff --git a/src/editor-vim-keys.ts b/src/editor-vim-keys.ts new file mode 100644 index 0000000..44a00c3 --- /dev/null +++ b/src/editor-vim-keys.ts @@ -0,0 +1,22 @@ +/** + * Pure key-chord helpers for modal editor vim modes (no DOM). + */ + +/** INSERT mode: Esc or Ctrl+[ returns to NORMAL. */ +export function insertModeKeyAction( + key: string, + opts: { ctrlKey: boolean; metaKey: boolean }, +): 'leave-normal' | 'pass' { + if (key === 'Escape' || (opts.ctrlKey && key === '[')) return 'leave-normal' + return 'pass' +} + +/** NORMAL mode count prefix — returns updated digits or null if not a count key. */ +export function tryAppendCountDigit(digits: string, key: string): string | null { + if (/^[1-9]$/.test(key) || (key === '0' && digits !== '')) { + let next = digits + key + if (next.length > 6) next = next.slice(0, 6) + return next + } + return null +} diff --git a/src/editor-vim-ops.test.ts b/src/editor-vim-ops.test.ts index 61436b5..ed32ed6 100644 --- a/src/editor-vim-ops.test.ts +++ b/src/editor-vim-ops.test.ts @@ -1,4 +1,19 @@ import { describe, it, expect } from 'vitest' +import { + applyReplaceRunsText, + deleteLineBlockText, + findNextOnLine, + joinLinesText, + lineEndCaretPos, + moveHorizPos, + pasteYankText, + reverseFindKind, + wordBackPos, + wordEndForwardPos, + wordForwardPos, + yankLineBlockText, +} from './editor-vim-ops' +import { insertModeKeyAction, tryAppendCountDigit } from './editor-vim-keys' import { consumeCountDigits, getLineCol, @@ -46,8 +61,126 @@ describe('consumeCountDigits', () => { describe('moveVertPos', () => { it('moves down preserving 0-based column offset (clamped to line length)', () => { const text = 'ab\nc\nde' - // caret on 'b' (index 1) → same column on "c" clamps to index 4 (after sole char) 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('deleteLineBlockText', () => { + it('removes lines and leaves caret at block start', () => { + const text = 'one\ntwo\nthree' + const result = deleteLineBlockText(text, 2, 2) + expect(result).toEqual({ text: 'one\n', pos: 4 }) + }) +}) + +describe('yankLineBlockText', () => { + it('includes trailing newline in register', () => { + const result = yankLineBlockText('a\nb\nc', 2, 1) + expect(result?.yank).toBe('b\n') + expect(result?.lineCount).toBe(1) + }) +}) + +describe('joinLinesText', () => { + it('merges consecutive lines', () => { + const result = joinLinesText('aa\nbb\ncc', 1, 2) + expect(result).toEqual({ text: 'aabbcc', pos: 4 }) + }) +}) + +describe('applyReplaceRunsText', () => { + it('replaces run and positions caret on last replaced char', () => { + const result = applyReplaceRunsText('hello', 1, 2, 'X') + expect(result).toEqual({ text: 'hXXlo', pos: 2 }) + }) +}) + +describe('pasteYankText', () => { + it('pastes after line when p', () => { + const result = pasteYankText('a\nb', 0, 'Y\n', true) + expect(result?.text).toBe('a\nY\nb') + }) + + it('pastes before line when P', () => { + const result = pasteYankText('a\nb', 2, 'Y\n', false) + expect(result?.text).toBe('a\nY\nb') + }) +}) + +describe('vim editing scenarios', () => { + it('yank-delete-paste round trip restores buffer', () => { + const original = 'line1\nline2\nline3' + const yanked = yankLineBlockText(original, 2, 1) + expect(yanked?.yank).toBe('line2\n') + + const deleted = deleteLineBlockText(original, 2, 1) + expect(deleted?.text).toBe('line1\nline3') + + 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-ops.ts b/src/editor-vim-ops.ts index f2523d1..004d8ab 100644 --- a/src/editor-vim-ops.ts +++ b/src/editor-vim-ops.ts @@ -62,9 +62,196 @@ export function moveVertPos(text: string, pos: number, delta: -1 | 1): number { 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 } +} diff --git a/src/editor-window.ts b/src/editor-window.ts index 41400a2..61e37b1 100644 --- a/src/editor-window.ts +++ b/src/editor-window.ts @@ -1,15 +1,29 @@ /** Modal editor over the fake VFS (normal / insert / ex); not the terminal one-line vim widget. */ import { parseEditorExCommand } from './editor-ex-commands' +import { insertModeKeyAction, tryAppendCountDigit } from './editor-vim-keys' import { + applyReplaceRunsText, consumeCountDigits, consumeOptionalNat, + deleteLineBlockText, + deleteThroughEOLText, + findNextOnLine, firstNonBlankOnLine, getLineCol, gotoLinePos, + joinLinesText, lineBounds, lineCountTotal, + lineEndCaretPos, + moveHorizPos, moveVertPos, + pasteYankText, + reverseFindKind, + wordBackPos, + wordEndForwardPos, + wordForwardPos, + yankLineBlockText, } from './editor-vim-ops' import { editorPathsEqual, editorWindowTitle } from './editor-window-meta' import { vfsFormatPath, vfsNormalize, vfsReadRaw, vfsWrite } from './os-fs' @@ -280,54 +294,29 @@ export class EditorWindow { } private deleteLineBlock(nLines: number): void { - const t = this.textarea.value - const curLineIdx = this.getLineCol().line - 1 - const lines = t.split('\n') - if (!lines.length) return - const del = Math.max(1, Math.min(nLines, lines.length - curLineIdx)) - let a = 0 - for (let i = 0; i < curLineIdx; i++) a += lines[i].length + 1 - let b = a - for (let i = curLineIdx; i < curLineIdx + del; i++) { - b += lines[i].length - if (i < lines.length - 1) b += 1 - } - const next = t.slice(0, a) + t.slice(b) - this.textarea.value = next - const caret = Math.min(a, next.length) - this.textarea.setSelectionRange(caret, caret) - this.dirty = next !== this.savedText + const line = this.getLineCol().line + const result = deleteLineBlockText(this.textarea.value, line, nLines) + 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() } private yankLineBlock(nLines: number): void { - const t = this.textarea.value - const curLineIdx = this.getLineCol().line - 1 - const lines = t.split('\n') - if (!lines.length) return - const take = Math.max(1, Math.min(nLines, lines.length - curLineIdx)) - let a = 0 - for (let i = 0; i < curLineIdx; i++) a += lines[i].length + 1 - let b = a - for (let i = curLineIdx; i < curLineIdx + take; i++) { - b += lines[i].length - if (i < lines.length - 1) b += 1 - } - this.yankRegister = t.slice(a, b) - if (!this.yankRegister.endsWith('\n')) this.yankRegister += '\n' - this.flashStatus(`Yanked ${take} line(s)`, false) + const result = yankLineBlockText(this.textarea.value, this.getLineCol().line, nLines) + if (!result) return + this.yankRegister = result.yank + this.flashStatus(`Yanked ${result.lineCount} line(s)`, false) } private deleteThroughEOL(): 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 + 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() @@ -336,24 +325,11 @@ export class EditorWindow { /** Join consecutive lines beginning at cursor line (minimal vi `J`): no extra spacing. */ private joinBelow(): void { const span = this.consumeCount(1) + 1 - const t = this.textarea.value - const lines = t.split('\n') - const cur = this.getLineCol().line - 1 - if (cur >= lines.length - 1) return - const maxSpan = Math.min(span, lines.length - cur) - const merged = lines.slice(cur, cur + maxSpan).join('') - let a = 0 - for (let i = 0; i < cur; i++) a += lines[i].length + 1 - let b = a - for (let i = cur; i < cur + maxSpan; i++) { - b += lines[i].length - if (i < lines.length - 1) b += 1 - } - const next = t.slice(0, a) + merged + t.slice(b) - this.textarea.value = next - const caret = Math.min(a + merged.length, next.length) - this.textarea.setSelectionRange(caret, caret) - this.dirty = next !== this.savedText + 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() @@ -361,16 +337,11 @@ export class EditorWindow { /** `r` / `{count}r`: replace each of the next `n` glyphs with `ch` (minimal vi semantics). */ private applyReplaceRuns(n: number, ch: string): void { - const t = this.textarea.value - const p = this.textarea.selectionStart - const take = Math.min(n, Math.max(0, t.length - p)) - if (take <= 0) return - const rep = ch.repeat(take) - const next = t.slice(0, p) + rep + t.slice(p + take) - this.textarea.value = next - const c = Math.max(p, Math.min(p + take - 1, next.length - 1)) - this.textarea.setSelectionRange(c, c) - this.dirty = next !== this.savedText + 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() @@ -721,7 +692,7 @@ export class EditorWindow { if (this.mode === 'cmd') return if (this.mode === 'insert') { - if (e.key === 'Escape' || (e.ctrlKey && e.key === '[')) { + if (insertModeKeyAction(e.key, { ctrlKey: e.ctrlKey, metaKey: e.metaKey }) === 'leave-normal') { e.preventDefault() this.recordAfterMutation() this.mode = 'normal' @@ -823,11 +794,10 @@ export class EditorWindow { return } - // Count prefix (`3j`, `10G`, `{count}` on `yy`/`dd`, …). - if (/^[1-9]$/.test(k) || (k === '0' && this.countDigits !== '')) { + const nextDigits = tryAppendCountDigit(this.countDigits, k) + if (nextDigits != null) { e.preventDefault() - this.countDigits += k - if (this.countDigits.length > 6) this.countDigits = this.countDigits.slice(0, 6) + this.countDigits = nextDigits this.refreshModeMeta() return } @@ -934,25 +904,17 @@ export class EditorWindow { // `:` prefix already cleared count const pasteAfter = (): void => { - const ya = this.yankRegister - if (!ya) return - let t = this.textarea.value - const p = cur() - const { end } = this.lineBounds(p) - const ins = end < t.length && t[end] === '\n' ? end + 1 : t.length - t = t.slice(0, ins) + ya + t.slice(ins) - this.textarea.value = t - setCur(ins + ya.length - 1) + 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 ya = this.yankRegister - if (!ya) return - let t = this.textarea.value - const { start } = this.lineBounds(cur()) - t = t.slice(0, start) + ya + t.slice(start) - this.textarea.value = t - setCur(start + ya.length - 1) + 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') { @@ -1012,11 +974,7 @@ export class EditorWindow { if (k === '$') { e.preventDefault() this.consumeCount(1) - const { start, end } = this.lineBounds(cur()) - const t = this.textarea.value - let p = end - 1 - if (p >= start && t[p] === '\n') p-- - setCur(Math.max(start, p)) + setCur(lineEndCaretPos(this.textarea.value, cur())) return } @@ -1101,7 +1059,8 @@ export class EditorWindow { e.preventDefault() const n = this.consumeCount(1) let p = cur() - for (let i = 0; i < n; i++) p = this.wordForward(p) + const t = this.textarea.value + for (let i = 0; i < n; i++) p = wordForwardPos(t, p) setCur(p) return } @@ -1109,7 +1068,8 @@ export class EditorWindow { e.preventDefault() const n = this.consumeCount(1) let p = cur() - for (let i = 0; i < n; i++) p = this.wordBack(p) + const t = this.textarea.value + for (let i = 0; i < n; i++) p = wordBackPos(t, p) setCur(p) return } @@ -1118,7 +1078,8 @@ export class EditorWindow { e.preventDefault() const n = this.consumeCount(1) let p = cur() - for (let i = 0; i < n; i++) p = this.wordEndForward(p) + const t = this.textarea.value + for (let i = 0; i < n; i++) p = wordEndForwardPos(t, p) setCur(p) return } @@ -1201,19 +1162,12 @@ export class EditorWindow { if (k === 'h') { e.preventDefault() - const n = this.consumeCount(1) - let p = cur() - for (let i = 0; i < n; i++) p = Math.max(0, p - 1) - setCur(p) + setCur(moveHorizPos(this.textarea.value, cur(), -1, this.consumeCount(1))) return } if (k === 'l') { e.preventDefault() - const n = this.consumeCount(1) - const max = this.textarea.value.length - let p = cur() - for (let i = 0; i < n; i++) p = Math.min(max, p + 1) - setCur(p) + setCur(moveHorizPos(this.textarea.value, cur(), 1, this.consumeCount(1))) return } if (k === 'j') { @@ -1270,72 +1224,12 @@ export class EditorWindow { return lineBounds(this.textarea.value, pos) } - private isWordChar(ch: string): boolean { - return /[A-Za-z0-9_]/.test(ch) - } - - /** Next word start — vim-like `w` on [A-Za-z0-9_] tokens */ - private wordForward(pos: number): number { - const t = this.textarea.value - let p = Math.min(pos, t.length) - while (p < t.length && !this.isWordChar(t[p]!)) p++ - while (p < t.length && this.isWordChar(t[p]!)) p++ - return p - } - - /** Previous word start — vim-like `b` */ - private wordBack(pos: number): number { - const t = this.textarea.value - let p = Math.min(pos, t.length) - if (p > 0) p-- - while (p > 0 && !this.isWordChar(t[p]!)) p-- - while (p > 0 && this.isWordChar(t[p - 1]!)) p-- - return p - } - - /** End of next word — vim-like `e` */ - private wordEndForward(pos: number): number { - const t = this.textarea.value - if (t.length === 0) return 0 - let p = Math.min(Math.max(0, pos), t.length - 1) - if (!this.isWordChar(t[p]!)) { - while (p < t.length && !this.isWordChar(t[p]!)) p++ - if (p >= t.length) return t.length - 1 - } - while (p < t.length - 1 && this.isWordChar(t[p + 1]!)) p++ - return p + 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' { - if (kind === 'f') return 'F' - if (kind === 'F') return 'f' - if (kind === 't') return 'T' - return 't' - } - - private findNextOnLine(kind: 'f' | 'F' | 't' | 'T', ch: string, fromPos: number): number | null { - const t = this.textarea.value - const { start, end } = this.lineBounds(fromPos) - const line = t.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 + return reverseFindKind(kind) } private repeatFindMotion( diff --git a/src/splitter.ts b/src/splitter.ts index aa0dc1d..f596cab 100644 --- a/src/splitter.ts +++ b/src/splitter.ts @@ -1,5 +1,5 @@ /** - * Pointer-based splitters (`h`: terminal vs stack width; `v`: height above pane). Mouse + touch. + * Pointer-based splitters for BSP tiling (`h`: column width; `v`: row height). Mouse + touch. */ export type SplitterOrientation = 'h' | 'v' diff --git a/src/styles/section-4.css b/src/styles/section-4.css index e5668cd..1c429bf 100644 --- a/src/styles/section-4.css +++ b/src/styles/section-4.css @@ -1,4 +1,4 @@ -/* ── Splitters (drag-to-resize handles) ───────────────────────────────────── */ +/* ── Splitters (BSP column + within-column drag handles) ───────────────────── */ .splitter { flex: 0 0 var(--ui-gap-wm); @@ -8,7 +8,6 @@ touch-action: none; } -.splitter-h { cursor: col-resize; } .splitter-v { cursor: col-resize; flex-basis: var(--ui-gap-wm); } /* Visible grip bar in the middle of each splitter */ @@ -23,16 +22,6 @@ transform var(--ui-duration-med) var(--ui-easing-spring); } -.splitter-h::before { - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 4px; - height: clamp(36px, 28%, var(--ui-touch-min)); - border-radius: 3px; - box-shadow: 0 0 0 1px rgba(var(--th-overlay-rgb), 0.35); -} - .splitter-v::before { top: 50%; left: 50%;