Skip to content

Commit 9811030

Browse files
committed
feat(game): implement command history
1 parent d94983b commit 9811030

File tree

2 files changed

+228
-18
lines changed

2 files changed

+228
-18
lines changed

electron/renderer/components/game/game-bottom-bar.tsx

+21-18
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { EuiFieldText } from '@elastic/eui';
2-
import type { KeyboardEventHandler, ReactNode } from 'react';
2+
import type { KeyboardEvent, KeyboardEventHandler, ReactNode } from 'react';
33
import { useCallback } from 'react';
44
import { isEmpty } from '../../../common/string/string.utils.js';
5+
import { useCommandHistory } from '../../hooks/command-history.jsx';
56
import { runInBackground } from '../../lib/async/run-in-background.js';
67

78
export interface GameBottomBarProps {
@@ -12,30 +13,32 @@ export interface GameBottomBarProps {
1213
export const GameBottomBar: React.FC<GameBottomBarProps> = (
1314
props: GameBottomBarProps
1415
): ReactNode => {
15-
// TODO move to a new GameCommandInput component
16-
const onKeyDownCommandInput = useCallback<
17-
KeyboardEventHandler<HTMLInputElement>
18-
>((event) => {
19-
const command = event.currentTarget.value;
20-
// TODO implement command history to track last N commands
21-
// pressing up/down arrow keys should cycle through history
22-
// pressing down arrow key when at the end of history should clear input
23-
// pressing up arrow key when at the beginning of history should do nothing
24-
if (event.code === 'Enter' && !isEmpty(command)) {
25-
event.currentTarget.value = '';
26-
runInBackground(async () => {
27-
await window.api.sendCommand(command);
28-
});
29-
}
30-
}, []);
16+
const { input, handleKeyDown, handleOnChange } = useCommandHistory();
17+
18+
const onKeyDown = useCallback<KeyboardEventHandler<HTMLInputElement>>(
19+
(event: KeyboardEvent<HTMLInputElement>) => {
20+
// Handle any history navigation.
21+
handleKeyDown(event);
22+
// Handle the "Enter" key to submit command to game.
23+
const command = event.currentTarget.value;
24+
if (event.code === 'Enter' && !isEmpty(command)) {
25+
runInBackground(async () => {
26+
await window.api.sendCommand(command);
27+
});
28+
}
29+
},
30+
[handleKeyDown]
31+
);
3132

3233
return (
3334
<EuiFieldText
35+
value={input}
3436
compressed={true}
3537
fullWidth={true}
3638
prepend={'RT'}
3739
tabIndex={0}
38-
onKeyDown={onKeyDownCommandInput}
40+
onKeyDown={onKeyDown}
41+
onChange={handleOnChange}
3942
/>
4043
);
4144
};
+207
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import type { ChangeEvent, KeyboardEvent } from 'react';
2+
import { useMemo } from 'react';
3+
import { create } from 'zustand';
4+
import { useShallow } from 'zustand/react/shallow';
5+
import { isBlank } from '../../common/string/string.utils.js';
6+
7+
export interface CommandHistory {
8+
/**
9+
* The current input value.
10+
* Bind this to your input element's value.
11+
*/
12+
input: string;
13+
/**
14+
* Imperatively set the input value.
15+
* Usually don't need to use this directly.
16+
* The `handleOnChange` method is more convenient.
17+
*/
18+
setInput: (input: string) => void;
19+
/**
20+
* Imperatively set the history index.
21+
* Usually don't need to use this directly.
22+
* The `navigateHistory` method is more convenient.
23+
*/
24+
setIndex: (index: number) => void;
25+
/**
26+
* Add a command to the history.
27+
* Usually don't need to use this directly.
28+
* The `handleKeyDown` method is more convenient.
29+
*/
30+
addCommand: (command: string) => void;
31+
/**
32+
* Imperatively navigate the command history.
33+
* Usually don't need to use this directly.
34+
* The `handleKeyDown` method is more convenient.
35+
*/
36+
navigateHistory: (direction: 'up' | 'down') => void;
37+
/**
38+
* Convenience method for navigating history based on keyboard events.
39+
* Bind this method to your input element's `onKeyDown` event.
40+
* The "up arrow" key navigates from newest to oldest commands.
41+
* The "down arrow" key navigates from oldest to newest commands.
42+
* The "enter" key adds the current input to the history.
43+
*/
44+
handleKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
45+
/**
46+
* Convenience method for updating the input value based on change events.
47+
* Bind this method to your input element's `onChange` event.
48+
* If you do not use this method, you must call `setInput` imperatively.
49+
*/
50+
handleOnChange: (event: ChangeEvent<HTMLInputElement>) => void;
51+
}
52+
53+
export const useCommandHistory = (): CommandHistory => {
54+
const store = useCommandHistoryStore(
55+
// Technically, our state reducer is returning a new object
56+
// each time although the properties are the same.
57+
// Use the `useShallow` operator to prevent unnecessary re-renders.
58+
useShallow((state) => {
59+
return {
60+
input: state.input,
61+
setInput: state.setInput,
62+
setIndex: state.setIndex,
63+
addCommand: state.addCommand,
64+
navigateHistory: state.navigateHistory,
65+
};
66+
})
67+
);
68+
69+
const api = useMemo(() => {
70+
return {
71+
input: store.input,
72+
setInput: (input: string) => {
73+
store.setInput(input);
74+
},
75+
setIndex: (index: number) => {
76+
store.setIndex(index);
77+
},
78+
addCommand: (command: string): void => {
79+
store.addCommand(command);
80+
},
81+
navigateHistory: (direction: 'up' | 'down'): void => {
82+
store.navigateHistory(direction);
83+
},
84+
handleKeyDown: (event: KeyboardEvent<HTMLInputElement>): void => {
85+
const command = event.currentTarget.value;
86+
if (event.code === 'ArrowUp') {
87+
store.navigateHistory('up');
88+
} else if (event.code === 'ArrowDown') {
89+
store.navigateHistory('down');
90+
} else if (event.code === 'Enter') {
91+
if (!isBlank(command)) {
92+
store.addCommand(command);
93+
store.setInput('');
94+
store.setIndex(-1);
95+
}
96+
}
97+
},
98+
handleOnChange: (event: ChangeEvent<HTMLInputElement>): void => {
99+
store.setInput(event.currentTarget.value);
100+
},
101+
};
102+
}, [store]);
103+
104+
return api;
105+
};
106+
107+
interface CommandHistoryData {
108+
/**
109+
* The current input value.
110+
* Usually bound to an input text field.
111+
*/
112+
input: string;
113+
/**
114+
* Any value that was in the input field before navigating the history.
115+
* This way if the user navigates back down to the beginning then
116+
* we can restore their original input.
117+
*/
118+
unsavedInput: string | null;
119+
/**
120+
* List of historical commands.
121+
* Commands are added to the front of the list.
122+
* The first element is the most recent command.
123+
* The last element is the oldest command.
124+
*/
125+
history: Array<string>;
126+
/**
127+
* The current index in the history.
128+
* `0` is the most recent command.
129+
* `history.length - 1` is the oldest command.
130+
* `-1` means to not be navigating, to show the current input.
131+
*/
132+
index: number;
133+
setInput: (input: string) => void;
134+
setIndex: (index: number) => void;
135+
addCommand: (command: string) => void;
136+
navigateHistory: (direction: 'up' | 'down') => void;
137+
}
138+
139+
const useCommandHistoryStore = create<CommandHistoryData>((set, get) => ({
140+
input: '',
141+
142+
unsavedInput: null,
143+
144+
history: Array<string>(),
145+
146+
index: -1,
147+
148+
setInput: (input: string): void => {
149+
set({ input, unsavedInput: null });
150+
},
151+
152+
setIndex: (index: number): void => {
153+
set({ index });
154+
},
155+
156+
addCommand: (command: string): void => {
157+
if (isBlank(command)) {
158+
return;
159+
}
160+
161+
const { history } = get();
162+
163+
// We push new commands to the front of the history.
164+
// So the previous command is the first element.
165+
const prevCmd = history[0];
166+
const currCmd = command.trim();
167+
168+
// Avoid storing duplicate back-to-back commmands.
169+
// Cap length of history to last N commands.
170+
if (prevCmd !== currCmd) {
171+
const newHistory = [currCmd, ...history];
172+
if (newHistory.length > 20) {
173+
newHistory.pop();
174+
}
175+
set({ history: newHistory, index: -1 });
176+
}
177+
},
178+
179+
navigateHistory: (direction: 'up' | 'down'): void => {
180+
const { history, index, unsavedInput } = get();
181+
182+
const minIndex = -1;
183+
const maxIndex = history.length;
184+
185+
let newIndex = index;
186+
187+
if (direction === 'up') {
188+
if (newIndex === minIndex) {
189+
// Save the current input before navigating.
190+
set({ unsavedInput: get().input });
191+
}
192+
newIndex = Math.min(newIndex + 1, maxIndex - 1);
193+
} else if (direction === 'down') {
194+
newIndex = Math.max(newIndex - 1, minIndex);
195+
}
196+
197+
set({ index: newIndex });
198+
199+
if (newIndex === minIndex) {
200+
// Restore the unsaved input.
201+
set({ input: unsavedInput ?? '' });
202+
} else {
203+
// Restore the historical command.
204+
set({ input: history[newIndex] ?? '' });
205+
}
206+
},
207+
}));

0 commit comments

Comments
 (0)