feat: prepare vim-sudoku for unified API implementation

- Archive previous implementation attempt (src -> src_failed)
- Create comprehensive IMPLEMENTATION_PLAN.md with phased approach
- Create IMPLEMENTATION.md to track implementation progress
- Update example app to match vim-maze patterns:
  - Simplify UI to desktop-only (no mobile support)
  - Add score metrics display (time, keystrokes, final score)
  - Update instructions for sudoku-specific keys (hjkl, i for edit)
  - Remove unnecessary mobile hooks

Phase 5 complete. Ready to begin Phase 10 (Setup & Core Types).
This commit is contained in:
2025-10-22 18:08:33 +01:00
parent d0a51d7079
commit 9292b83dd0
25 changed files with 368 additions and 85 deletions
+12
View File
@@ -0,0 +1,12 @@
export { useCursor } from './useCursor';
export { useVimMotions } from './useVimMotions';
export type {
CursorManager,
CursorMode,
Coord,
Motion,
VimMotionSystem,
GameKeyManager,
KeyLogEntry,
CursorRenderer
} from './types';
+63
View File
@@ -0,0 +1,63 @@
import type { RefObject } from 'react';
import type { BoardManager } from '../useBoard/types';
export type Coord = { row: number; col: number };
export type CursorMode = 'normal' | 'edit';
export type Motion = {
dr: number;
dc: number;
steps: number;
};
export type CursorManager = {
position: () => Coord;
mode: () => CursorMode;
move: (dRows: number, dCols: number, count: number) => void;
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;
resetCount: () => void;
getCount: () => string;
hasCount: () => boolean;
setCount: (digit: string) => void;
setLastKey: (key: string) => void;
getLastKey: () => string;
setMode: (mode: CursorMode) => void;
flashInvalid: () => void;
flashValid: () => void;
flashLocked: () => void;
highlightConflicts: (conflictIndices: number[]) => void;
};
export type VimMotionSystem = {
processKey: (key: string) => { type: 'move' | 'anchor' | 'repeat' | 'count' | 'digit' | 'erase' | 'mode', data: any } | null;
resetCount: () => void;
getCount: () => number;
hasCount: () => boolean;
setLastMotion: (motion: Motion) => void;
repeatLastMotion: () => Motion | null;
};
export type GameKeyManager = {
keyLog: KeyLogEntry[];
clearKeyLog: () => void;
getKeyLog: () => KeyLogEntry[];
editMode: CursorMode;
setEditMode: (mode: CursorMode) => void;
};
export type KeyLogEntry = { key: string; timestamp: number };
export type CursorRenderer = {
highlightCursor: (row: number, col: number, editMode: boolean) => void;
flashCell: (row: number, col: number, className: string, duration?: number) => void;
highlightConflicts: (conflictIndices: number[], duration?: number) => void;
};
+275
View File
@@ -0,0 +1,275 @@
import { useState, useCallback, useEffect } from 'react';
import type { BoardManager } from '../useBoard/types';
import type { CursorManager, CursorMode, Coord, VimMotionSystem, GameKeyManager, KeyLogEntry } from './types';
import { useVimMotions } from './useVimMotions';
export function useCursor(board: BoardManager): CursorManager & { keyManager: GameKeyManager } {
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
const [mode, setMode] = useState<CursorMode>('normal');
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
const vimMotions = useVimMotions();
const addKeyToLog = useCallback((key: string) => {
setKeyLog(prev => [...prev, { key, timestamp: Date.now() }]);
}, []);
const move = useCallback((dRows: number, dCols: number, count: number) => {
setPosition(prev => {
const newRow = prev.row + (dRows * count);
const newCol = prev.col + (dCols * count);
if (newRow < 0 || newRow > 8 || newCol < 0 || newCol > 8) {
flashInvalid();
return prev;
}
return { row: newRow, col: newCol };
});
}, []);
const moveLeft = useCallback((count = 1) => {
move(0, -1, count);
}, [move]);
const moveRight = useCallback((count = 1) => {
move(0, 1, count);
}, [move]);
const moveUp = useCallback((count = 1) => {
move(-1, 0, count);
}, [move]);
const moveDown = useCallback((count = 1) => {
move(1, 0, count);
}, [move]);
const moveToStart = useCallback(() => {
setPosition(prev => ({ ...prev, col: 0 }));
}, []);
const moveToEnd = useCallback(() => {
setPosition(prev => ({ ...prev, col: 8 }));
}, []);
const moveToTop = useCallback(() => {
setPosition({ row: 0, col: 0 });
}, []);
const moveToBottom = useCallback(() => {
setPosition({ row: 8, col: 0 });
}, []);
const repeatLastMotion = useCallback(() => {
const lastMotion = vimMotions.repeatLastMotion();
if (lastMotion) {
move(lastMotion.dr, lastMotion.dc, lastMotion.steps);
}
}, [vimMotions, move]);
const resetCount = useCallback(() => {
vimMotions.resetCount();
}, [vimMotions]);
const getCount = useCallback(() => {
return vimMotions.getCount().toString();
}, [vimMotions]);
const hasCount = useCallback(() => {
return vimMotions.hasCount();
}, [vimMotions]);
const setCount = useCallback((digit: string) => {
vimMotions.processKey(digit);
}, [vimMotions]);
const setLastKey = useCallback((key: string) => {
addKeyToLog(key);
}, [addKeyToLog]);
const getLastKey = useCallback(() => {
return keyLog[keyLog.length - 1]?.key || '';
}, [keyLog]);
const flashInvalid = useCallback(() => {
const container = board.containerRef.current;
if (!container) return;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('invalid-move');
setTimeout(() => cellEl.classList.remove('invalid-move'), 180);
}
}, [board, position]);
const flashValid = useCallback(() => {
const container = board.containerRef.current;
if (!container) return;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('valid-move');
setTimeout(() => cellEl.classList.remove('valid-move'), 300);
}
}, [board, position]);
const flashLocked = useCallback(() => {
const container = board.containerRef.current;
if (!container) return;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('locked-cell');
setTimeout(() => cellEl.classList.remove('locked-cell'), 300);
}
}, [board, position]);
const highlightConflicts = useCallback((conflictIndices: number[]) => {
const container = board.containerRef.current;
if (!container) return;
conflictIndices.forEach((idx) => {
const row = Math.floor(idx / 9);
const col = idx % 9;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('conflict');
}
});
setTimeout(() => {
conflictIndices.forEach((idx) => {
const row = Math.floor(idx / 9);
const col = idx % 9;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.remove('conflict');
}
});
}, 2000);
}, [board]);
const clearKeyLog = useCallback(() => {
setKeyLog([]);
}, []);
const getKeyLog = useCallback(() => {
return [...keyLog];
}, [keyLog]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const key = e.key;
const result = vimMotions.processKey(key);
if (!result) return;
addKeyToLog(key);
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 'mode':
setMode(result.data);
break;
case 'digit':
if (mode === 'edit') {
const success = board.setCell(position.row, position.col, result.data);
if (success) {
flashValid();
} else if (!board.canEditCell(position.row, position.col)) {
flashLocked();
} else {
const conflicts = board.getConflicts(position.row, position.col);
if (conflicts.length > 0) {
highlightConflicts(conflicts);
}
flashInvalid();
}
}
break;
case 'erase':
if (mode === 'edit') {
const success = board.setCell(position.row, position.col, '.');
if (success) {
flashValid();
} else if (!board.canEditCell(position.row, position.col)) {
flashLocked();
} else {
flashInvalid();
}
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [vimMotions, addKeyToLog, move, moveToStart, moveToEnd, moveToTop, moveToBottom, mode, board, position, flashValid, flashLocked, flashInvalid, highlightConflicts]);
const cursorManager: CursorManager = {
position: () => position,
mode: () => mode,
move,
moveLeft,
moveRight,
moveUp,
moveDown,
moveToStart,
moveToEnd,
moveToTop,
moveToBottom,
repeatLastMotion,
resetCount,
getCount,
hasCount,
setCount,
setLastKey,
getLastKey,
setMode,
flashInvalid,
flashValid,
flashLocked,
highlightConflicts
};
const keyManager: GameKeyManager = {
keyLog,
clearKeyLog,
getKeyLog,
editMode: mode,
setEditMode: setMode
};
return {
...cursorManager,
keyManager
};
}
+118
View File
@@ -0,0 +1,118 @@
import { useRef } from 'react';
import type { VimMotionSystem, Motion } from './types';
export function useVimMotions(): VimMotionSystem {
const countRef = useRef<string>('');
const lastMotionRef = useRef<Motion | null>(null);
const processKey = (key: string) => {
if (key >= '0' && key <= '9') {
if (key === '0' && countRef.current === '') {
return { type: 'anchor', data: 'start' };
}
countRef.current += key;
return { type: 'count', data: key };
}
const count = countRef.current ? parseInt(countRef.current, 10) : 1;
switch (key) {
case 'h':
const motionH = { dr: 0, dc: -1, steps: count };
lastMotionRef.current = motionH;
countRef.current = '';
return { type: 'move', data: motionH };
case 'j':
const motionJ = { dr: 1, dc: 0, steps: count };
lastMotionRef.current = motionJ;
countRef.current = '';
return { type: 'move', data: motionJ };
case 'k':
const motionK = { dr: -1, dc: 0, steps: count };
lastMotionRef.current = motionK;
countRef.current = '';
return { type: 'move', data: motionK };
case 'l':
const motionL = { dr: 0, dc: 1, steps: count };
lastMotionRef.current = motionL;
countRef.current = '';
return { type: 'move', data: motionL };
case '^':
countRef.current = '';
return { type: 'anchor', data: 'start' };
case '$':
countRef.current = '';
return { type: 'anchor', data: 'end' };
case 'g':
countRef.current = '';
return { type: 'anchor', data: 'top' };
case 'G':
countRef.current = '';
return { type: 'anchor', data: 'bottom' };
case '.':
const lastMotion = lastMotionRef.current;
if (lastMotion) {
return { type: 'repeat', data: lastMotion };
}
return null;
case 'i':
countRef.current = '';
return { type: 'mode', data: 'edit' };
case 'Escape':
countRef.current = '';
return { type: 'mode', data: 'normal' };
case 'x':
case 'Delete':
case 'Backspace':
countRef.current = '';
return { type: 'erase', data: null };
default:
if (key >= '1' && key <= '9') {
countRef.current = '';
return { type: 'digit', data: key };
}
return null;
}
};
const resetCount = () => {
countRef.current = '';
};
const getCount = () => {
return countRef.current ? parseInt(countRef.current, 10) : 1;
};
const hasCount = () => {
return countRef.current !== '';
};
const setLastMotion = (motion: Motion) => {
lastMotionRef.current = motion;
};
const repeatLastMotion = () => {
return lastMotionRef.current;
};
return {
processKey,
resetCount,
getCount,
hasCount,
setLastMotion,
repeatLastMotion
};
}