Skip to content

Commit 79d832f

Browse files
committed
feat: use physical key as hotkey
Closes will-stone#469
1 parent 180a5c0 commit 79d832f

File tree

18 files changed

+275
-59
lines changed

18 files changed

+275
-59
lines changed

Diff for: __fixtures__/key-layout.ts

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
export const keyLayout = {
2+
KeyE: 'e',
3+
KeyD: 'd',
4+
Minus: '-',
5+
KeyH: 'h',
6+
KeyZ: 'z',
7+
Equal: '=',
8+
KeyP: 'p',
9+
BracketRight: ']',
10+
BracketLeft: '[',
11+
Digit8: '8',
12+
Digit9: '9',
13+
KeyS: 's',
14+
Semicolon: ';',
15+
Digit5: '5',
16+
KeyQ: 'q',
17+
KeyO: 'o',
18+
Period: '.',
19+
Digit6: '6',
20+
KeyV: 'v',
21+
Digit3: '3',
22+
KeyL: 'l',
23+
Backquote: '`',
24+
KeyG: 'g',
25+
KeyJ: 'j',
26+
KeyT: 't',
27+
Quote: "'",
28+
KeyY: 'y',
29+
Digit0: '0',
30+
IntlBackslash: '§',
31+
KeyR: 'r',
32+
Backslash: '\\',
33+
KeyU: 'u',
34+
KeyK: 'k',
35+
Slash: '/',
36+
KeyF: 'f',
37+
KeyI: 'i',
38+
KeyX: 'x',
39+
KeyA: 'a',
40+
Digit2: '2',
41+
Digit7: '7',
42+
KeyM: 'm',
43+
Digit4: '4',
44+
KeyW: 'w',
45+
IntlYen: '§',
46+
Digit1: '1',
47+
KeyN: 'n',
48+
KeyB: 'b',
49+
IntlRo: '`',
50+
KeyC: 'c',
51+
Comma: ',',
52+
}

Diff for: src/renderers/picker/components/hooks/use-keyboard-events.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ const keyboardEvent =
1616
const { apps } = getState().storage
1717

1818
const virtualKey = event.key.toLowerCase()
19-
// Not needed at the moment but useful to know
20-
// const physicalKey = event.code.toLowerCase()
19+
const physicalKey = event.code
2120

2221
// Escape
2322
if (virtualKey === 'escape') {
@@ -39,12 +38,13 @@ const keyboardEvent =
3938
}
4039
}
4140

42-
// App hotkey -- no modifier key held AND is alphanumeric
43-
else if (!event.metaKey && /^[a-z0-9]$/u.test(virtualKey)) {
44-
event.preventDefault()
45-
const foundApp = apps.find((app) => app.hotkey === virtualKey)
41+
// App hotkey
42+
else {
43+
const foundApp = apps.find((app) => app.hotCode === physicalKey)
44+
45+
if (!event.metaKey && foundApp) {
46+
event.preventDefault()
4647

47-
if (foundApp) {
4848
dispatch(
4949
pressedAppKey({
5050
url,

Diff for: src/renderers/picker/components/layout.tsx

+8-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useDispatch } from 'react-redux'
44
import { pickerStarted } from '../../../shared/state/actions'
55
import { useInstalledApps } from '../../../shared/state/hooks'
66
import { Spinner } from '../../shared/components/atoms/spinner'
7+
import { useKeyCodeMap } from '../../shared/state/hooks'
78
import { favAppRef } from '../refs'
89
import AppLogo from './atoms/app-logo'
910
import Kbd from './atoms/kbd'
@@ -32,6 +33,8 @@ const App: React.FC = () => {
3233

3334
const [favApp, ...normalApps] = useInstalledApps()
3435

36+
const keyCodeMap = useKeyCodeMap()
37+
3538
return (
3639
<div className="h-screen w-screen select-none flex flex-col items-center relative dark:text-white">
3740
{!favApp && (
@@ -49,7 +52,9 @@ const App: React.FC = () => {
4952
>
5053
<AppLogo app={favApp} className="h-20 w-20 mb-2" />
5154
<span>{favApp.name}</span>
52-
{favApp.hotkey && <Kbd className="mt-2">{favApp.hotkey}</Kbd>}
55+
{favApp.hotCode && (
56+
<Kbd className="mt-2">{keyCodeMap[favApp.hotCode]}</Kbd>
57+
)}
5358
</AppButton>
5459
)}
5560
</div>
@@ -64,8 +69,8 @@ const App: React.FC = () => {
6469
className="flex-shrink-0 flex items-center text-left px-4 py-3 space-x-4 w-full"
6570
>
6671
<AppLogo app={app} className="flex-shrink-0 h-8 w-8" />
67-
{app.hotkey && (
68-
<Kbd className="flex-shrink-0">{app.hotkey}</Kbd>
72+
{app.hotCode && (
73+
<Kbd className="flex-shrink-0">{keyCodeMap[app.hotCode]}</Kbd>
6974
)}
7075
<span>{app.name}</span>
7176
</AppButton>

Diff for: src/renderers/picker/components/organisms/apps.test.tsx

+25-5
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import '../../../shared/preload'
22

33
import { fireEvent, render, screen } from '@testing-library/react'
44
import electron from 'electron'
5+
import cloneDeep from 'lodash/cloneDeep'
56
import React from 'react'
67

8+
import { keyLayout } from '../../../../../__fixtures__/key-layout'
79
import {
810
clickedApp,
911
installedAppsRetrieved,
@@ -13,8 +15,26 @@ import {
1315
urlOpened,
1416
} from '../../../../shared/state/actions'
1517
import { Channel } from '../../../../shared/state/channels'
18+
import { customWindow } from '../../../shared/custom.window'
1619
import Wrapper from '../_bootstrap'
1720

21+
const originalNavigator = cloneDeep(customWindow.navigator)
22+
23+
beforeAll(() => {
24+
customWindow.navigator = {
25+
...customWindow.navigator,
26+
keyboard: {
27+
getLayoutMap: jest
28+
.fn()
29+
.mockResolvedValue({ entries: jest.fn().mockReturnValue(keyLayout) }),
30+
},
31+
}
32+
})
33+
34+
afterAll(() => {
35+
customWindow.navigator = originalNavigator
36+
})
37+
1838
test('apps', () => {
1939
render(<Wrapper />)
2040
const win = new electron.BrowserWindow()
@@ -42,9 +62,9 @@ test('apps', () => {
4262
Channel.MAIN,
4363
syncStorage({
4464
apps: [
45-
{ id: 'org.mozilla.firefox', hotkey: null },
46-
{ id: 'com.apple.Safari', hotkey: null },
47-
{ id: 'com.brave.Browser.nightly', hotkey: null },
65+
{ id: 'org.mozilla.firefox', hotkey: null, hotCode: null },
66+
{ id: 'com.apple.Safari', hotkey: null, hotCode: null },
67+
{ id: 'com.brave.Browser.nightly', hotkey: null, hotCode: null },
4868
],
4969
supportMessage: -1,
5070
height: 200,
@@ -91,7 +111,7 @@ test('use hotkey', () => {
91111
win.webContents.send(
92112
Channel.MAIN,
93113
syncStorage({
94-
apps: [{ id: 'com.apple.Safari', hotkey: 's' }],
114+
apps: [{ id: 'com.apple.Safari', hotkey: 's', hotCode: 'KeyS' }],
95115
supportMessage: -1,
96116
height: 200,
97117
firstRun: false,
@@ -123,7 +143,7 @@ test('use hotkey with alt', () => {
123143
win.webContents.send(
124144
Channel.MAIN,
125145
syncStorage({
126-
apps: [{ id: 'com.apple.Safari', hotkey: 's' }],
146+
apps: [{ id: 'com.apple.Safari', hotkey: 's', hotCode: 'KeyS' }],
127147
supportMessage: -1,
128148
height: 200,
129149
firstRun: false,

Diff for: src/renderers/picker/components/organisms/url-bar.test.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import '../../../shared/preload'
33
import type { MatcherFunction } from '@testing-library/react'
44
import { act, render, screen } from '@testing-library/react'
55
import electron from 'electron'
6+
import cloneDeep from 'lodash/cloneDeep'
67
import React from 'react'
78

9+
import { keyLayout } from '../../../../../__fixtures__/key-layout'
810
import { urlOpened } from '../../../../shared/state/actions'
911
import { Channel } from '../../../../shared/state/channels'
12+
import { customWindow } from '../../../shared/custom.window'
1013
import Wrapper from '../_bootstrap'
1114

1215
const multiElementText =
@@ -20,6 +23,23 @@ const multiElementText =
2023
return nodeHasText && childrenDontHaveText
2124
}
2225

26+
const originalNavigator = cloneDeep(customWindow.navigator)
27+
28+
beforeAll(() => {
29+
customWindow.navigator = {
30+
...customWindow.navigator,
31+
keyboard: {
32+
getLayoutMap: jest
33+
.fn()
34+
.mockResolvedValue({ entries: jest.fn().mockReturnValue(keyLayout) }),
35+
},
36+
}
37+
})
38+
39+
afterAll(() => {
40+
customWindow.navigator = originalNavigator
41+
})
42+
2343
test('url bar', () => {
2444
render(<Wrapper />)
2545
const win = new electron.BrowserWindow()

Diff for: src/renderers/picker/state/middleware.ts

+26-17
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,37 @@ import {
66
installedAppsRetrieved,
77
urlOpened,
88
} from '../../../shared/state/actions'
9+
import type { Channel } from '../../../shared/state/channels'
910
import type { Middleware } from '../../../shared/state/model'
11+
import { getKeyLayout } from '../../shared/state/thunk.get-key-layout-map'
1012
import { favAppRef } from '../refs'
1113

1214
/**
1315
* Pass actions between main and renderers
1416
*/
15-
export const pickerMiddleware = (): Middleware => () => (next) => (action) => {
16-
/**
17-
* Move to next middleware
18-
*/
19-
// eslint-disable-next-line node/callback-return -- must flush to get nextState
20-
const result = next(action)
17+
export const pickerMiddleware =
18+
(channel: Channel): Middleware =>
19+
({ dispatch }) =>
20+
(next) =>
21+
(action) => {
22+
// eslint-disable-next-line node/callback-return -- Move to next middleware
23+
const result = next(action)
2124

22-
if (
23-
urlOpened.match(action) ||
24-
clickedRestorePicker.match(action) ||
25-
installedAppsRetrieved.match(action) ||
26-
clickedDonate.match(action) ||
27-
clickedMaybeLater.match(action)
28-
) {
29-
favAppRef.current?.focus()
30-
}
25+
const doesActionOpenPicker =
26+
urlOpened.match(action) || clickedRestorePicker.match(action)
27+
28+
if (
29+
doesActionOpenPicker ||
30+
installedAppsRetrieved.match(action) ||
31+
clickedDonate.match(action) ||
32+
clickedMaybeLater.match(action)
33+
) {
34+
favAppRef.current?.focus()
35+
}
3136

32-
return result
33-
}
37+
if (doesActionOpenPicker) {
38+
dispatch(getKeyLayout(channel))
39+
}
40+
41+
return result
42+
}

Diff for: src/renderers/picker/state/store.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { customWindow } from '../../shared/custom.window'
66
import { busMiddleware } from '../../shared/state/middleware.bus'
77
import { pickerMiddleware } from './middleware'
88

9-
const store = createStore([busMiddleware(Channel.PICKER), pickerMiddleware()])
9+
const store = createStore([
10+
busMiddleware(Channel.PICKER),
11+
pickerMiddleware(Channel.PICKER),
12+
])
1013

1114
export default store
1215

Diff for: src/renderers/prefs/components/organisms/pane-apps.tsx

+12-9
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'
1111
import { useDispatch } from 'react-redux'
1212

1313
import { apps } from '../../../../config/apps'
14-
import { changedHotkey, reorderedApps } from '../../../../shared/state/actions'
14+
import { changedHotCode, reorderedApps } from '../../../../shared/state/actions'
1515
import { useInstalledApps } from '../../../../shared/state/hooks'
1616
import Input from '../../../shared/components/atoms/input'
1717
import { Spinner } from '../../../shared/components/atoms/spinner'
18+
import { useKeyCodeMap } from '../../../shared/state/hooks'
1819
import { Pane } from '../molecules/pane'
1920

2021
interface DragDirectionArrowProps {
@@ -61,6 +62,8 @@ export function AppsPane(): JSX.Element {
6162
)
6263
}
6364

65+
const keyCodeMap = useKeyCodeMap()
66+
6467
return (
6568
<Pane pane="apps">
6669
{installedApps.length === 0 && (
@@ -77,7 +80,7 @@ export function AppsPane(): JSX.Element {
7780
className="overflow-y-auto p-2"
7881
{...droppableProvided.droppableProps}
7982
>
80-
{installedApps.map(({ id, name, hotkey }, index) => (
83+
{installedApps.map(({ id, name, hotCode }, index) => (
8184
<Draggable key={id} draggableId={id} index={index}>
8285
{(draggableProvided, draggableSnapshot) => (
8386
<div
@@ -127,20 +130,20 @@ export function AppsPane(): JSX.Element {
127130
data-app-id={id}
128131
maxLength={1}
129132
minLength={0}
130-
onChange={(event) => {
133+
onFocus={(event) => {
134+
event.target.select()
135+
}}
136+
onKeyPress={(event) => {
131137
dispatch(
132-
changedHotkey({
138+
changedHotCode({
133139
appId: id,
134-
value: event.currentTarget.value,
140+
value: event.code,
135141
}),
136142
)
137143
}}
138-
onFocus={(event) => {
139-
event.target.select()
140-
}}
141144
placeholder="Key"
142145
type="text"
143-
value={hotkey || ''}
146+
value={keyCodeMap[hotCode || ''] || ''}
144147
/>
145148
</div>
146149
</div>

Diff for: src/renderers/prefs/state/middleware.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/* eslint-disable unicorn/prefer-regexp-test */
2+
import { clickedOpenPrefs } from '../../../shared/state/actions'
3+
import type { Channel } from '../../../shared/state/channels'
4+
import type { Middleware } from '../../../shared/state/model'
5+
import { getKeyLayout } from '../../shared/state/thunk.get-key-layout-map'
6+
7+
/**
8+
* Pass actions between main and renderers
9+
*/
10+
export const prefsMiddleware =
11+
(channel: Channel): Middleware =>
12+
({ dispatch }) =>
13+
(next) =>
14+
(action) => {
15+
// eslint-disable-next-line node/callback-return -- Move to next middleware
16+
const result = next(action)
17+
18+
if (clickedOpenPrefs.match(action)) {
19+
dispatch(getKeyLayout(channel))
20+
}
21+
22+
return result
23+
}

Diff for: src/renderers/prefs/state/store.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import { Channel } from '../../../shared/state/channels'
44
import createStore from '../../../shared/state/create-store'
55
import { customWindow } from '../../shared/custom.window'
66
import { busMiddleware } from '../../shared/state/middleware.bus'
7+
import { prefsMiddleware } from './middleware'
78

8-
const store = createStore([busMiddleware(Channel.PREFS)])
9+
const store = createStore([
10+
busMiddleware(Channel.PREFS),
11+
prefsMiddleware(Channel.PREFS),
12+
])
913

1014
export default store
1115

0 commit comments

Comments
 (0)