wpmx 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wpmx",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "A minimal, fast typing test for the terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.tsx CHANGED
@@ -21,9 +21,8 @@ export type GameResults = {
21
21
 
22
22
  export function App() {
23
23
  const { exit } = useApp();
24
- const settings = loadSettings();
25
24
  const [screen, setScreen] = useState<Screen>("menu");
26
- const [duration, setDuration] = useState<Duration>(settings.lastDuration);
25
+ const [duration, setDuration] = useState<Duration>(() => loadSettings().lastDuration);
27
26
  const [results, setResults] = useState<GameResults | null>(null);
28
27
  const [gameKey, setGameKey] = useState(0);
29
28
 
@@ -1,6 +1,33 @@
1
1
  import { Box, Text, useInput } from "ink";
2
2
  import { useGame, type GameResults } from "../hooks/useGame.ts";
3
- import { useEffect } from "react";
3
+ import { memo, useEffect, useMemo } from "react";
4
+
5
+ const PastWord = memo(({ word, inputChars }: { word: string; inputChars: string[] }) => (
6
+ <Box marginRight={1}>
7
+ {word.split("").map((char, charIndex) => {
8
+ const inputChar = inputChars[charIndex];
9
+ return (
10
+ <Text key={charIndex} color={inputChar === char ? "green" : "red"}>
11
+ {char}
12
+ </Text>
13
+ );
14
+ })}
15
+ {inputChars.length > word.length &&
16
+ inputChars
17
+ .slice(word.length)
18
+ .map((char, i) => (
19
+ <Text key={`extra-${i}`} color="red" strikethrough>
20
+ {char}
21
+ </Text>
22
+ ))}
23
+ </Box>
24
+ ));
25
+
26
+ const FutureWord = memo(({ word }: { word: string }) => (
27
+ <Box marginRight={1}>
28
+ <Text dimColor>{word}</Text>
29
+ </Box>
30
+ ));
4
31
 
5
32
  type GameProps = {
6
33
  duration: number;
@@ -31,32 +58,22 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
31
58
 
32
59
  if (key.backspace || key.delete) {
33
60
  game.handleBackspace();
34
- } else if (input === " ") {
35
- game.handleSpace();
36
- } else if (input && !key.ctrl && !key.meta && input.length === 1) {
37
- game.handleChar(input);
61
+ } else if (input && !key.ctrl && !key.meta) {
62
+ for (const char of input) {
63
+ if (char === " ") {
64
+ game.handleSpace();
65
+ } else if (char >= "!" && char <= "~") {
66
+ game.handleChar(char);
67
+ }
68
+ }
38
69
  }
39
70
  });
40
71
 
41
- const liveWpm = game.isRunning && !game.isFinished
42
- ? Math.round(
43
- (() => {
44
- let correct = 0;
45
- for (let i = 0; i < game.currentWordIndex; i++) {
46
- const word = game.words[i];
47
- const input = game.charInputs[i].join("");
48
- if (input === word) correct += word.length + 1;
49
- else {
50
- for (let j = 0; j < word.length; j++) {
51
- if (input[j] === word[j]) correct++;
52
- }
53
- }
54
- }
55
- const elapsed = (duration - game.timeLeft) || 1;
56
- return (correct / 5) / (elapsed / 60);
57
- })()
58
- )
59
- : 0;
72
+ const liveWpm = useMemo(() => {
73
+ if (!game.isRunning || game.isFinished) return 0;
74
+ const elapsed = (duration - game.timeLeft) || 1;
75
+ return Math.round((game.correctCharsAcc / 5) / (elapsed / 60));
76
+ }, [game.correctCharsAcc, game.timeLeft, game.isRunning, game.isFinished, duration]);
60
77
 
61
78
  return (
62
79
  <Box flexDirection="column" paddingX={2} paddingY={1}>
@@ -70,36 +87,11 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
70
87
  </Box>
71
88
  <Box marginTop={1} flexWrap="wrap">
72
89
  {game.words.slice(0, Math.min(game.words.length, game.currentWordIndex + 30)).map((word, wordIndex) => {
73
- const isCurrentWord = wordIndex === game.currentWordIndex;
74
- const isPast = wordIndex < game.currentWordIndex;
75
- const wordResult = game.wordResults[wordIndex];
76
-
77
- if (isPast) {
78
- const inputChars = game.charInputs[wordIndex];
79
- return (
80
- <Box key={wordIndex} marginRight={1}>
81
- {word.split("").map((char, charIndex) => {
82
- const inputChar = inputChars[charIndex];
83
- const isCorrect = inputChar === char;
84
- return (
85
- <Text key={charIndex} color={isCorrect ? "green" : "red"}>
86
- {char}
87
- </Text>
88
- );
89
- })}
90
- {inputChars.length > word.length &&
91
- inputChars
92
- .slice(word.length)
93
- .map((char, i) => (
94
- <Text key={`extra-${i}`} color="red" strikethrough>
95
- {char}
96
- </Text>
97
- ))}
98
- </Box>
99
- );
90
+ if (wordIndex < game.currentWordIndex) {
91
+ return <PastWord key={wordIndex} word={word} inputChars={game.charInputs[wordIndex]} />;
100
92
  }
101
93
 
102
- if (isCurrentWord) {
94
+ if (wordIndex === game.currentWordIndex) {
103
95
  return (
104
96
  <Box key={wordIndex} marginRight={1}>
105
97
  {word.split("").map((char, charIndex) => {
@@ -118,15 +110,8 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
118
110
  </Text>
119
111
  );
120
112
  }
121
- if (inputChar === char) {
122
- return (
123
- <Text key={charIndex} color="green">
124
- {char}
125
- </Text>
126
- );
127
- }
128
113
  return (
129
- <Text key={charIndex} color="red">
114
+ <Text key={charIndex} color={inputChar === char ? "green" : "red"}>
130
115
  {char}
131
116
  </Text>
132
117
  );
@@ -144,11 +129,7 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
144
129
  );
145
130
  }
146
131
 
147
- return (
148
- <Box key={wordIndex} marginRight={1}>
149
- <Text dimColor>{word}</Text>
150
- </Box>
151
- );
132
+ return <FutureWord key={wordIndex} word={word} />;
152
133
  })}
153
134
  </Box>
154
135
  </Box>
@@ -8,8 +8,16 @@ export type GameState = {
8
8
  timeLeft: number;
9
9
  isRunning: boolean;
10
10
  isFinished: boolean;
11
- wordResults: Array<"correct" | "incorrect" | "pending">;
11
+ };
12
+
13
+ export type GameHook = GameState & {
12
14
  charInputs: string[][];
15
+ correctCharsAcc: number;
16
+ startGame: () => void;
17
+ handleChar: (char: string) => void;
18
+ handleBackspace: () => void;
19
+ handleSpace: () => void;
20
+ getResults: () => GameResults;
13
21
  };
14
22
 
15
23
  export type GameResults = {
@@ -28,15 +36,29 @@ function generateWords(count: number): string[] {
28
36
 
29
37
  export function useGame(duration: number) {
30
38
  const wordCount = Math.max(duration * 3, 50);
31
- const [state, setState] = useState<GameState>({
32
- words: generateWords(wordCount),
33
- currentWordIndex: 0,
34
- currentInput: "",
35
- timeLeft: duration,
36
- isRunning: false,
37
- isFinished: false,
38
- wordResults: Array(wordCount).fill("pending"),
39
- charInputs: Array(wordCount).fill(null).map(() => []),
39
+ const charInputsRef = useRef<string[][]>(
40
+ Array.from({ length: wordCount }, () => [])
41
+ );
42
+ const wordsRef = useRef<string[]>([]);
43
+ const currentWordIndexRef = useRef(0);
44
+ const isFinishedRef = useRef(false);
45
+ const isRunningRef = useRef(false);
46
+ const correctCharsAccRef = useRef(0);
47
+ const totalCharsAccRef = useRef(0);
48
+ const wordCorrectCharsRef = useRef<number[]>([]);
49
+ const wordTotalCharsRef = useRef<number[]>([]);
50
+
51
+ const [state, setState] = useState<GameState>(() => {
52
+ const generatedWords = generateWords(wordCount);
53
+ wordsRef.current = generatedWords;
54
+ return {
55
+ words: generatedWords,
56
+ currentWordIndex: 0,
57
+ currentInput: "",
58
+ timeLeft: duration,
59
+ isRunning: false,
60
+ isFinished: false,
61
+ };
40
62
  });
41
63
 
42
64
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -45,132 +67,123 @@ export function useGame(duration: number) {
45
67
  useEffect(() => {
46
68
  if (state.isRunning && !state.isFinished) {
47
69
  timerRef.current = setInterval(() => {
70
+ const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
71
+ const newTimeLeft = Math.max(duration - elapsed, 0);
48
72
  setState((prev) => {
49
- const newTimeLeft = prev.timeLeft - 1;
73
+ if (prev.timeLeft === newTimeLeft) return prev;
50
74
  if (newTimeLeft <= 0) {
51
75
  if (timerRef.current) clearInterval(timerRef.current);
76
+ isFinishedRef.current = true;
77
+ isRunningRef.current = false;
52
78
  return { ...prev, timeLeft: 0, isRunning: false, isFinished: true };
53
79
  }
54
80
  return { ...prev, timeLeft: newTimeLeft };
55
81
  });
56
- }, 1000);
82
+ }, 200);
57
83
  }
58
84
  return () => {
59
85
  if (timerRef.current) clearInterval(timerRef.current);
60
86
  };
61
- }, [state.isRunning, state.isFinished]);
87
+ }, [state.isRunning, state.isFinished, duration]);
62
88
 
63
89
  const startGame = useCallback(() => {
64
90
  startTimeRef.current = Date.now();
91
+ isRunningRef.current = true;
65
92
  setState((prev) => ({ ...prev, isRunning: true }));
66
93
  }, []);
67
94
 
68
95
  const handleChar = useCallback((char: string) => {
69
- setState((prev) => {
70
- if (prev.isFinished) return prev;
71
- if (!prev.isRunning) {
72
- startTimeRef.current = Date.now();
73
- const newCharInputs = [...prev.charInputs];
74
- newCharInputs[prev.currentWordIndex] = [
75
- ...newCharInputs[prev.currentWordIndex],
76
- char,
77
- ];
78
- return {
79
- ...prev,
80
- isRunning: true,
81
- currentInput: prev.currentInput + char,
82
- charInputs: newCharInputs,
83
- };
84
- }
85
- const newCharInputs = [...prev.charInputs];
86
- newCharInputs[prev.currentWordIndex] = [
87
- ...newCharInputs[prev.currentWordIndex],
88
- char,
89
- ];
90
- return {
91
- ...prev,
92
- currentInput: prev.currentInput + char,
93
- charInputs: newCharInputs,
94
- };
95
- });
96
+ if (isFinishedRef.current) return;
97
+ charInputsRef.current[currentWordIndexRef.current].push(char);
98
+ if (!isRunningRef.current) {
99
+ startTimeRef.current = Date.now();
100
+ isRunningRef.current = true;
101
+ }
102
+ setState((prev) => ({
103
+ ...prev,
104
+ isRunning: true,
105
+ currentInput: prev.currentInput + char,
106
+ }));
96
107
  }, []);
97
108
 
98
109
  const handleBackspace = useCallback(() => {
99
- setState((prev) => {
100
- if (prev.isFinished) return prev;
101
-
102
- // If current input is empty, go back to previous word
103
- if (prev.currentInput.length === 0) {
104
- if (prev.currentWordIndex === 0) return prev;
105
- const prevIndex = prev.currentWordIndex - 1;
106
- const prevInput = prev.charInputs[prevIndex].join("");
107
- const newWordResults = [...prev.wordResults];
108
- newWordResults[prevIndex] = "pending";
109
- return {
110
- ...prev,
111
- currentWordIndex: prevIndex,
112
- currentInput: prevInput,
113
- wordResults: newWordResults,
114
- };
115
- }
116
-
117
- // Otherwise delete last char of current word
118
- const newCharInputs = [...prev.charInputs];
119
- newCharInputs[prev.currentWordIndex] = newCharInputs[
120
- prev.currentWordIndex
121
- ].slice(0, -1);
122
- return {
110
+ if (isFinishedRef.current) return;
111
+
112
+ const currentIdx = currentWordIndexRef.current;
113
+ const currentChars = charInputsRef.current[currentIdx];
114
+
115
+ if (currentChars.length === 0) {
116
+ if (currentIdx === 0) return;
117
+ const prevIndex = currentIdx - 1;
118
+ currentWordIndexRef.current = prevIndex;
119
+ const lastCorrect = wordCorrectCharsRef.current.pop() || 0;
120
+ const lastTotal = wordTotalCharsRef.current.pop() || 0;
121
+ correctCharsAccRef.current -= lastCorrect;
122
+ totalCharsAccRef.current -= lastTotal;
123
+ const prevInput = charInputsRef.current[prevIndex].join("");
124
+ setState((prev) => ({
125
+ ...prev,
126
+ currentWordIndex: prevIndex,
127
+ currentInput: prevInput,
128
+ }));
129
+ } else {
130
+ charInputsRef.current[currentIdx].pop();
131
+ setState((prev) => ({
123
132
  ...prev,
124
133
  currentInput: prev.currentInput.slice(0, -1),
125
- charInputs: newCharInputs,
126
- };
127
- });
134
+ }));
135
+ }
128
136
  }, []);
129
137
 
130
138
  const handleSpace = useCallback(() => {
131
- setState((prev) => {
132
- if (prev.isFinished || !prev.isRunning) return prev;
133
- const word = prev.words[prev.currentWordIndex];
134
- const isCorrect = prev.currentInput === word;
135
- const newWordResults = [...prev.wordResults];
136
- newWordResults[prev.currentWordIndex] = isCorrect
137
- ? "correct"
138
- : "incorrect";
139
- return {
140
- ...prev,
141
- currentWordIndex: prev.currentWordIndex + 1,
142
- currentInput: "",
143
- wordResults: newWordResults,
144
- };
145
- });
146
- }, []);
147
-
148
- const getResults = useCallback((): GameResults => {
149
- let correctChars = 0;
150
- let totalChars = 0;
151
-
152
- for (let i = 0; i < state.currentWordIndex; i++) {
153
- const word = state.words[i];
154
- const input = state.charInputs[i].join("");
155
- totalChars += Math.max(word.length, input.length) + 1;
156
- if (input === word) {
157
- correctChars += word.length + 1;
158
- } else {
159
- for (let j = 0; j < word.length; j++) {
160
- if (input[j] === word[j]) correctChars++;
161
- }
139
+ if (isFinishedRef.current || !isRunningRef.current) return;
140
+
141
+ const currentIdx = currentWordIndexRef.current;
142
+ const word = wordsRef.current[currentIdx];
143
+ const inputChars = charInputsRef.current[currentIdx];
144
+ const input = inputChars.join("");
145
+
146
+ let correct = 0;
147
+ if (input === word) {
148
+ correct = word.length + 1;
149
+ } else {
150
+ for (let j = 0; j < word.length; j++) {
151
+ if (inputChars[j] === word[j]) correct++;
162
152
  }
163
153
  }
154
+ const total = Math.max(word.length, input.length) + 1;
155
+
156
+ wordCorrectCharsRef.current.push(correct);
157
+ wordTotalCharsRef.current.push(total);
158
+ correctCharsAccRef.current += correct;
159
+ totalCharsAccRef.current += total;
160
+ currentWordIndexRef.current = currentIdx + 1;
161
+
162
+ setState((prev) => ({
163
+ ...prev,
164
+ currentWordIndex: currentIdx + 1,
165
+ currentInput: "",
166
+ }));
167
+ }, []);
164
168
 
165
- const timeElapsed = duration;
166
- const wpm = totalChars > 0 ? Math.round((correctChars / 5) / (timeElapsed / 60)) : 0;
167
- const accuracy = totalChars > 0 ? Math.round((correctChars / totalChars) * 1000) / 10 : 0;
168
-
169
+ const getResults = useCallback((): GameResults => {
170
+ const correctChars = correctCharsAccRef.current;
171
+ const totalChars = totalCharsAccRef.current;
172
+ const wpm =
173
+ totalChars > 0
174
+ ? Math.round((correctChars / 5) / (duration / 60))
175
+ : 0;
176
+ const accuracy =
177
+ totalChars > 0
178
+ ? Math.round((correctChars / totalChars) * 1000) / 10
179
+ : 0;
169
180
  return { wpm, accuracy, time: duration };
170
- }, [state.currentWordIndex, state.charInputs, state.words, duration]);
181
+ }, [duration]);
171
182
 
172
183
  return {
173
184
  ...state,
185
+ charInputs: charInputsRef.current,
186
+ correctCharsAcc: correctCharsAccRef.current,
174
187
  startGame,
175
188
  handleChar,
176
189
  handleBackspace,