From ef25d3873b96df0216ba1674ca3426a65d4f9038 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Wed, 22 Oct 2025 19:14:21 +0100 Subject: [PATCH] feat: implement vim anchor moves and motion system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added complete vim motion system with anchor moves: New Files: - src/useCursor/useVimMotions.ts: Vim motion processor - Handles number prefixes (counts) - Special 0 handling: anchor when alone, count when prefixed - Tracks last motion for repeat (.) Anchor Moves: - 0 or ^ → Move to start of row (col 0) - $ → Move to end of row (col 8) - gg → Move to top of column (row 0) - G → Move to bottom of column (row 8) Count Support: - 5j → Move down 5 cells - 10l → Move right 10 cells (bounded to 8) - Works with all directional moves (h/j/k/l) Repeat Motion: - . → Repeat last motion with same count Key Logic (0 handling): - If 0 pressed with empty count buffer → anchor to start - If 0 pressed with existing count → add to count (10j, 20k) - Prevents conflict between anchor and counted moves Updated: - useCursor.ts: Integrated useVimMotions processor - types.ts: Added Motion and VimMotionSystem types - CursorManager: Added anchor and repeat methods All moves respect 9x9 bounds with red flash on invalid. --- src/useCursor/types.ts | 16 +++++ src/useCursor/useCursor.ts | 81 ++++++++++++++++++++++--- src/useCursor/useVimMotions.ts | 104 +++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 src/useCursor/useVimMotions.ts diff --git a/src/useCursor/types.ts b/src/useCursor/types.ts index b6ffee5..061c288 100644 --- a/src/useCursor/types.ts +++ b/src/useCursor/types.ts @@ -1,9 +1,25 @@ export type Coord = { row: number; col: number }; +export type Motion = { dr: number; dc: number; steps: number }; + +export type VimMotionSystem = { + processKey: (key: string) => { type: 'move' | 'anchor' | 'repeat' | 'count'; data: any } | null; + resetCount: () => void; + getCount: () => number; + hasCount: () => boolean; + setLastMotion: (motion: Motion) => void; + repeatLastMotion: () => Motion | null; +}; + export type CursorManager = { position: () => Coord; moveLeft: (count?: number) => void; moveRight: (count?: number) => void; moveUp: (count?: number) => void; moveDown: (count?: number) => void; + moveToStart: () => void; + moveToEnd: () => void; + moveToTop: () => void; + moveToBottom: () => void; + repeatLastMotion: () => void; }; diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts index 85091e7..d455eff 100644 --- a/src/useCursor/useCursor.ts +++ b/src/useCursor/useCursor.ts @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react'; import type { Coord, CursorManager } from './types'; import type { GameStatusManager } from '../useGameStatus/types'; +import { useVimMotions } from './useVimMotions'; export function useCursor(gameStatusManager: GameStatusManager, containerRef: React.RefObject) { const [position, setPosition] = useState({ row: 0, col: 0 }); const { gameStatus } = gameStatusManager; + const vimMotions = useVimMotions(); function flashInvalidMove(): void { const container = containerRef.current; @@ -52,20 +54,78 @@ export function useCursor(gameStatusManager: GameStatusManager, containerRef: Re move(1, 0, count); } + function moveToStart(): void { + setPosition(prev => ({ ...prev, col: 0 })); + } + + function moveToEnd(): void { + setPosition(prev => ({ ...prev, col: 8 })); + } + + function moveToTop(): void { + setPosition(prev => ({ row: 0, col: prev.col })); + } + + function moveToBottom(): void { + setPosition(prev => ({ row: 8, col: prev.col })); + } + + function repeatLastMotion(): void { + const motion = vimMotions.repeatLastMotion(); + if (!motion) return; + + setPosition(currentPos => { + const newRow = currentPos.row + (motion.dr * motion.steps); + const newCol = currentPos.col + (motion.dc * motion.steps); + + if (newRow < 0 || newRow > 8 || newCol < 0 || newCol > 8) { + flashInvalidMove(); + return currentPos; + } + + return { row: newRow, col: newCol }; + }); + } + useEffect(() => { if (gameStatus !== 'started') return; function handleKeyDown(e: KeyboardEvent) { const key = e.key; + const result = vimMotions.processKey(key); - if (key === 'h') { - moveLeft(); - } else if (key === 'j') { - moveDown(); - } else if (key === 'k') { - moveUp(); - } else if (key === 'l') { - moveRight(); + if (!result) return; + + switch (result.type) { + case 'move': + move(result.data.dr, result.data.dc, result.data.steps); + break; + + case 'anchor': + switch (result.data) { + case 'start': + moveToStart(); + break; + case 'end': + moveToEnd(); + break; + case 'top': + moveToTop(); + break; + case 'bottom': + moveToBottom(); + break; + } + break; + + case 'repeat': + if (result.data) { + move(result.data.dr, result.data.dc, result.data.steps); + } + break; + + case 'count': + break; } } @@ -79,6 +139,11 @@ export function useCursor(gameStatusManager: GameStatusManager, containerRef: Re moveRight, moveUp, moveDown, + moveToStart, + moveToEnd, + moveToTop, + moveToBottom, + repeatLastMotion, }; return cursor; diff --git a/src/useCursor/useVimMotions.ts b/src/useCursor/useVimMotions.ts new file mode 100644 index 0000000..16e6dab --- /dev/null +++ b/src/useCursor/useVimMotions.ts @@ -0,0 +1,104 @@ +import { useRef } from 'react'; +import type { VimMotionSystem, Motion } from './types'; + +export function useVimMotions(): VimMotionSystem { + const countRef = useRef(''); + const lastMotionRef = useRef(null); + + function processKey(key: string): { type: 'move'|'anchor'|'repeat'|'count', data: any } | null { + if (key >= '0' && key <= '9') { + if (key === '0' && countRef.current === '') { + return { type: 'anchor', data: 'start' }; + } + countRef.current += key; + return { type: 'count', data: key }; + } + + if (key === 'h') { + const motion = { dr: 0, dc: -1, steps: getCount() }; + setLastMotion(motion); + resetCount(); + return { type: 'move', data: motion }; + } + + if (key === 'j') { + const motion = { dr: 1, dc: 0, steps: getCount() }; + setLastMotion(motion); + resetCount(); + return { type: 'move', data: motion }; + } + + if (key === 'k') { + const motion = { dr: -1, dc: 0, steps: getCount() }; + setLastMotion(motion); + resetCount(); + return { type: 'move', data: motion }; + } + + if (key === 'l') { + const motion = { dr: 0, dc: 1, steps: getCount() }; + setLastMotion(motion); + resetCount(); + return { type: 'move', data: motion }; + } + + if (key === '^') { + resetCount(); + return { type: 'anchor', data: 'start' }; + } + + if (key === '$') { + resetCount(); + return { type: 'anchor', data: 'end' }; + } + + if (key === 'g') { + resetCount(); + return { type: 'anchor', data: 'top' }; + } + + if (key === 'G') { + resetCount(); + return { type: 'anchor', data: 'bottom' }; + } + + if (key === '.') { + const motion = repeatLastMotion(); + if (motion) { + resetCount(); + return { type: 'repeat', data: motion }; + } + } + + return null; + } + + function resetCount(): void { + countRef.current = ''; + } + + function getCount(): number { + return Math.max(1, parseInt(countRef.current || '1', 10)); + } + + function hasCount(): boolean { + return countRef.current !== ''; + } + + function setLastMotion(motion: Motion): void { + lastMotionRef.current = motion; + } + + function repeatLastMotion(): Motion | null { + return lastMotionRef.current; + } + + return { + processKey, + resetCount, + getCount, + hasCount, + setLastMotion, + repeatLastMotion + }; +}