wpmx 1.0.3 → 1.0.4

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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "enabledMcpjsonServers": [
3
+ "shadcn"
4
+ ],
5
+ "enableAllProjectMcpServers": true
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wpmx",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "A minimal, fast typing test for the terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.tsx CHANGED
@@ -21,6 +21,10 @@ export type GameResults = {
21
21
 
22
22
  export function App() {
23
23
  const { exit } = useApp();
24
+ const handleQuit = useCallback(() => {
25
+ exit();
26
+ process.exit(0);
27
+ }, [exit]);
24
28
  const [screen, setScreen] = useState<Screen>("menu");
25
29
  const [duration, setDuration] = useState<Duration>(() => loadSettings().lastDuration);
26
30
  const [results, setResults] = useState<GameResults | null>(null);
@@ -57,7 +61,7 @@ export function App() {
57
61
  }, []);
58
62
 
59
63
  if (screen === "menu") {
60
- return <Menu onStart={handleStart} onQuit={exit} defaultDuration={duration} />;
64
+ return <Menu onStart={handleStart} onQuit={handleQuit} defaultDuration={duration} />;
61
65
  }
62
66
 
63
67
  if (screen === "game") {
@@ -80,7 +84,7 @@ export function App() {
80
84
  personalBest={pb}
81
85
  onRestart={handleRestart}
82
86
  onMenu={handleMenu}
83
- onQuit={exit}
87
+ onQuit={handleQuit}
84
88
  />
85
89
  );
86
90
  }
@@ -1,33 +1,15 @@
1
- import { Box, Text, useInput } from "ink";
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import type { Key } from "ink";
2
3
  import { useGame, type GameResults } from "../hooks/useGame.ts";
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
+ import { useEffect, useMemo, useCallback, useState } from "react";
5
+
6
+ type StyledChar = {
7
+ char: string;
8
+ color?: string;
9
+ dimColor?: boolean;
10
+ strikethrough?: boolean;
11
+ isCursor?: boolean;
12
+ };
31
13
 
32
14
  type GameProps = {
33
15
  duration: number;
@@ -38,6 +20,7 @@ type GameProps = {
38
20
 
39
21
  export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
40
22
  const game = useGame(duration);
23
+ const { stdout } = useStdout();
41
24
 
42
25
  useEffect(() => {
43
26
  if (game.isFinished) {
@@ -45,29 +28,40 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
45
28
  }
46
29
  }, [game.isFinished]);
47
30
 
48
- useInput((input, key) => {
49
- if (key.escape) {
50
- onExit();
51
- return;
52
- }
53
- if (key.tab) {
54
- onRestart();
55
- return;
56
- }
57
- if (game.isFinished) return;
58
-
59
- if (key.backspace || key.delete) {
60
- game.handleBackspace();
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);
31
+ const handleInput = useCallback(
32
+ (input: string, key: Key) => {
33
+ if (key.escape) {
34
+ onExit();
35
+ return;
36
+ }
37
+ if (key.tab) {
38
+ onRestart();
39
+ return;
40
+ }
41
+ if (game.isFinished) return;
42
+
43
+ if (key.backspace || key.delete) {
44
+ game.handleBackspace();
45
+ } else if (input && !key.ctrl && !key.meta) {
46
+ for (const char of input) {
47
+ if (char === " ") {
48
+ game.handleSpace();
49
+ } else if (char >= "!" && char <= "~") {
50
+ game.handleChar(char);
51
+ }
67
52
  }
68
53
  }
69
- }
70
- });
54
+ },
55
+ [onExit, onRestart, game.isFinished, game.handleBackspace, game.handleSpace, game.handleChar],
56
+ );
57
+
58
+ useInput(handleInput);
59
+
60
+ const [cursorVisible, setCursorVisible] = useState(true);
61
+ useEffect(() => {
62
+ const id = setInterval(() => setCursorVisible((v) => !v), 530);
63
+ return () => clearInterval(id);
64
+ }, []);
71
65
 
72
66
  const liveWpm = useMemo(() => {
73
67
  if (!game.isRunning || game.isFinished) return 0;
@@ -75,9 +69,78 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
75
69
  return Math.round((game.correctCharsAcc / 5) / (elapsed / 60));
76
70
  }, [game.correctCharsAcc, game.timeLeft, game.isRunning, game.isFinished, duration]);
77
71
 
72
+ const termWidth = stdout.columns || 80;
73
+ const availableWidth = termWidth - 4;
74
+ const centerCol = Math.floor(availableWidth / 2);
75
+ const currentWordIdx = game.currentWordIndex;
76
+
77
+ // Build flat styled character array for all words (cursor style applied at render time)
78
+ const { chars, cursorPos } = useMemo(() => {
79
+ const chars: StyledChar[] = [];
80
+ let cursorPos = 0;
81
+
82
+ for (let wi = 0; wi < game.words.length; wi++) {
83
+ const word = game.words[wi];
84
+ const isPast = wi < currentWordIdx;
85
+ const isCurrent = wi === currentWordIdx;
86
+
87
+ if (isPast) {
88
+ const inputs = game.charInputs[wi] || [];
89
+ for (let ci = 0; ci < word.length; ci++) {
90
+ chars.push({ char: word[ci], color: inputs[ci] === word[ci] ? "green" : "red" });
91
+ }
92
+ for (let ci = word.length; ci < inputs.length; ci++) {
93
+ chars.push({ char: inputs[ci], color: "red", strikethrough: true });
94
+ }
95
+ chars.push({ char: " " });
96
+ } else if (isCurrent) {
97
+ const typedInWord = Math.min(game.currentInput.length, word.length);
98
+ for (let ci = 0; ci < typedInWord; ci++) {
99
+ chars.push({ char: word[ci], color: game.currentInput[ci] === word[ci] ? "green" : "red" });
100
+ }
101
+ if (game.currentInput.length < word.length) {
102
+ // Cursor on next untyped character
103
+ cursorPos = chars.length;
104
+ chars.push({ char: word[game.currentInput.length], isCursor: true });
105
+ for (let ci = game.currentInput.length + 1; ci < word.length; ci++) {
106
+ chars.push({ char: word[ci], dimColor: true });
107
+ }
108
+ chars.push({ char: " " });
109
+ } else {
110
+ // Extra typed chars beyond word length
111
+ for (let ci = word.length; ci < game.currentInput.length; ci++) {
112
+ chars.push({ char: game.currentInput[ci], color: "red" });
113
+ }
114
+ // Cursor at space position
115
+ cursorPos = chars.length;
116
+ chars.push({ char: " ", isCursor: true });
117
+ }
118
+ } else {
119
+ // Future word
120
+ for (let ci = 0; ci < word.length; ci++) {
121
+ chars.push({ char: word[ci], dimColor: true });
122
+ }
123
+ chars.push({ char: " " });
124
+ }
125
+ }
126
+
127
+ return { chars, cursorPos };
128
+ }, [game.words, game.charInputs, currentWordIdx, game.currentInput]);
129
+
130
+ // Extract visible window centered on cursor
131
+ const windowStart = cursorPos - centerCol;
132
+ const visibleChars: StyledChar[] = [];
133
+ for (let i = windowStart; i < windowStart + availableWidth; i++) {
134
+ if (i >= 0 && i < chars.length) {
135
+ visibleChars.push(chars[i]);
136
+ } else {
137
+ visibleChars.push({ char: " " });
138
+ }
139
+ }
140
+
78
141
  return (
79
142
  <Box flexDirection="column" paddingX={2} paddingY={1}>
80
- <Box justifyContent="space-between">
143
+ <Box justifyContent="center" gap={2}>
81
144
  <Text color="yellow" bold>
82
145
  {game.timeLeft}s
83
146
  </Text>
@@ -85,52 +148,32 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
85
148
  {liveWpm} wpm
86
149
  </Text>
87
150
  </Box>
88
- <Box marginTop={1} flexWrap="wrap">
89
- {game.words.slice(0, Math.min(game.words.length, game.currentWordIndex + 30)).map((word, wordIndex) => {
90
- if (wordIndex < game.currentWordIndex) {
91
- return <PastWord key={wordIndex} word={word} inputChars={game.charInputs[wordIndex]} />;
92
- }
93
-
94
- if (wordIndex === game.currentWordIndex) {
151
+ <Box marginTop={1}>
152
+ <Text>
153
+ {visibleChars.map((sc, i) => {
154
+ if (sc.isCursor) {
155
+ return cursorVisible ? (
156
+ <Text key={i} backgroundColor="white" color="black">
157
+ {sc.char}
158
+ </Text>
159
+ ) : (
160
+ <Text key={i} underline>
161
+ {sc.char}
162
+ </Text>
163
+ );
164
+ }
95
165
  return (
96
- <Box key={wordIndex} marginRight={1}>
97
- {word.split("").map((char, charIndex) => {
98
- const inputChar = game.currentInput[charIndex];
99
- if (inputChar === undefined && charIndex === game.currentInput.length) {
100
- return (
101
- <Text key={charIndex} color="white" bold underline>
102
- {char}
103
- </Text>
104
- );
105
- }
106
- if (inputChar === undefined) {
107
- return (
108
- <Text key={charIndex} dimColor>
109
- {char}
110
- </Text>
111
- );
112
- }
113
- return (
114
- <Text key={charIndex} color={inputChar === char ? "green" : "red"}>
115
- {char}
116
- </Text>
117
- );
118
- })}
119
- {game.currentInput.length > word.length &&
120
- game.currentInput
121
- .slice(word.length)
122
- .split("")
123
- .map((char, i) => (
124
- <Text key={`extra-${i}`} color="red">
125
- {char}
126
- </Text>
127
- ))}
128
- </Box>
166
+ <Text
167
+ key={i}
168
+ color={sc.color}
169
+ dimColor={sc.dimColor}
170
+ strikethrough={sc.strikethrough}
171
+ >
172
+ {sc.char}
173
+ </Text>
129
174
  );
130
- }
131
-
132
- return <FutureWord key={wordIndex} word={word} />;
133
- })}
175
+ })}
176
+ </Text>
134
177
  </Box>
135
178
  </Box>
136
179
  );
@@ -79,7 +79,7 @@ export function useGame(duration: number) {
79
79
  }
80
80
  return { ...prev, timeLeft: newTimeLeft };
81
81
  });
82
- }, 200);
82
+ }, 1000);
83
83
  }
84
84
  return () => {
85
85
  if (timerRef.current) clearInterval(timerRef.current);