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:
@@ -1,9 +1,25 @@
|
|||||||
export type Coord = { row: number; col: number };
|
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 = {
|
export type CursorManager = {
|
||||||
position: () => Coord;
|
position: () => Coord;
|
||||||
moveLeft: (count?: number) => void;
|
moveLeft: (count?: number) => void;
|
||||||
moveRight: (count?: number) => void;
|
moveRight: (count?: number) => void;
|
||||||
moveUp: (count?: number) => void;
|
moveUp: (count?: number) => void;
|
||||||
moveDown: (count?: number) => void;
|
moveDown: (count?: number) => void;
|
||||||
|
moveToStart: () => void;
|
||||||
|
moveToEnd: () => void;
|
||||||
|
moveToTop: () => void;
|
||||||
|
moveToBottom: () => void;
|
||||||
|
repeatLastMotion: () => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { Coord, CursorManager } from './types';
|
import type { Coord, CursorManager } from './types';
|
||||||
import type { GameStatusManager } from '../useGameStatus/types';
|
import type { GameStatusManager } from '../useGameStatus/types';
|
||||||
|
import { useVimMotions } from './useVimMotions';
|
||||||
|
|
||||||
export function useCursor(gameStatusManager: GameStatusManager, containerRef: React.RefObject<HTMLDivElement | null>) {
|
export function useCursor(gameStatusManager: GameStatusManager, containerRef: React.RefObject<HTMLDivElement | null>) {
|
||||||
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
|
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
|
||||||
const { gameStatus } = gameStatusManager;
|
const { gameStatus } = gameStatusManager;
|
||||||
|
const vimMotions = useVimMotions();
|
||||||
|
|
||||||
function flashInvalidMove(): void {
|
function flashInvalidMove(): void {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -52,20 +54,78 @@ export function useCursor(gameStatusManager: GameStatusManager, containerRef: Re
|
|||||||
move(1, 0, count);
|
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(() => {
|
useEffect(() => {
|
||||||
if (gameStatus !== 'started') return;
|
if (gameStatus !== 'started') return;
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
const key = e.key;
|
const key = e.key;
|
||||||
|
const result = vimMotions.processKey(key);
|
||||||
|
|
||||||
if (key === 'h') {
|
if (!result) return;
|
||||||
moveLeft();
|
|
||||||
} else if (key === 'j') {
|
switch (result.type) {
|
||||||
moveDown();
|
case 'move':
|
||||||
} else if (key === 'k') {
|
move(result.data.dr, result.data.dc, result.data.steps);
|
||||||
moveUp();
|
break;
|
||||||
} else if (key === 'l') {
|
|
||||||
moveRight();
|
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,
|
moveRight,
|
||||||
moveUp,
|
moveUp,
|
||||||
moveDown,
|
moveDown,
|
||||||
|
moveToStart,
|
||||||
|
moveToEnd,
|
||||||
|
moveToTop,
|
||||||
|
moveToBottom,
|
||||||
|
repeatLastMotion,
|
||||||
};
|
};
|
||||||
|
|
||||||
return cursor;
|
return cursor;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user