feat: implement vim anchor moves and motion system

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.
This commit is contained in:
2025-10-22 19:14:21 +01:00
parent 38b9b4b694
commit ef25d3873b
3 changed files with 193 additions and 8 deletions
+16
View File
@@ -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;
};
+73 -8
View File
@@ -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<HTMLDivElement | null>) {
const [position, setPosition] = useState<Coord>({ 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;
+104
View File
@@ -0,0 +1,104 @@
import { useRef } from 'react';
import type { VimMotionSystem, Motion } from './types';
export function useVimMotions(): VimMotionSystem {
const countRef = useRef<string>('');
const lastMotionRef = useRef<Motion | null>(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
};
}