Skip to content

Commit b91a995

Browse files
dlorchIDisposable
andauthored
feat(ui): enable multiple keyboard layouts for "paste text" to remote host (#405)
* Enable multiple keyboard layouts for paste text from host * Trema is the more robust method for capital umlauts * Improve error handling and pre-loading * Improve accent handling * Remove obscure Alt-Gr keys, unsure if they are supported everywhere * Add Swiss French * Change line ordering * Fix whitespace * Add French (France) * Add English (UK) * Add Swedish * Add Spanish * Fix fr_FR special characters * Add more keys to Spanish * Remove default value shift: false * Add Norwegian * Operator precedence 🤦 * Add Italian * Add Czech * Move guard statements outside of loop * Move language name definitions into the keyboard layout files * Change the locale names to their native language German->Deutsch et. al. * Move hold key handling into Go backend analogous to https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt * Remove trailing whitespace * Fix * Add Belgisch Nederlands * Add JSONRPC handling * Use useSettingsStore * Revert "Move hold key handling into Go backend analogous to https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt" This reverts commit 146cee9. * Move FeatureFlag to navigation * Fix: flip Y/Z * Add useEffect dependencies * Embolden language * Add to useCallback dependencies --------- Co-authored-by: Marc Brooks <IDisposable@gmail.com>
1 parent 590c606 commit b91a995

22 files changed

+1942
-133
lines changed

config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ type Config struct {
8787
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
8888
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
8989
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
90+
KeyboardLayout string `json:"keyboard_layout"`
9091
EdidString string `json:"hdmi_edid_string"`
9192
ActiveExtension string `json:"active_extension"`
9293
DisplayRotation string `json:"display_rotation"`
@@ -109,6 +110,7 @@ var defaultConfig = &Config{
109110
ActiveExtension: "",
110111
KeyboardMacros: []KeyboardMacro{},
111112
DisplayRotation: "270",
113+
KeyboardLayout: "en-US",
112114
DisplayMaxBrightness: 64,
113115
DisplayDimAfterSec: 120, // 2 minutes
114116
DisplayOffAfterSec: 1800, // 30 minutes

jsonrpc.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,18 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error {
901901
return nil
902902
}
903903

904+
func rpcGetKeyboardLayout() (string, error) {
905+
return config.KeyboardLayout, nil
906+
}
907+
908+
func rpcSetKeyboardLayout(layout string) error {
909+
config.KeyboardLayout = layout
910+
if err := SaveConfig(); err != nil {
911+
return fmt.Errorf("failed to save config: %w", err)
912+
}
913+
return nil
914+
}
915+
904916
func getKeyboardMacros() (interface{}, error) {
905917
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
906918
copy(macros, config.KeyboardMacros)
@@ -1066,6 +1078,8 @@ var rpcHandlers = map[string]RPCHandler{
10661078
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
10671079
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
10681080
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
1081+
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
1082+
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
10691083
"getKeyboardMacros": {Func: getKeyboardMacros},
10701084
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
10711085
}

ui/src/components/popovers/PasteModal.tsx

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,21 @@ import { GridCard } from "@components/Card";
88
import { TextAreaWithLabel } from "@components/TextArea";
99
import { SettingsPageHeader } from "@components/SettingsPageheader";
1010
import { useJsonRpc } from "@/hooks/useJsonRpc";
11-
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
12-
import { chars, keys, modifiers } from "@/keyboardMappings";
11+
import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
12+
import { keys, modifiers } from "@/keyboardMappings";
13+
import { layouts, chars } from "@/keyboardLayouts";
1314
import notifications from "@/notifications";
1415

1516
const hidKeyboardPayload = (keys: number[], modifier: number) => {
1617
return { keys, modifier };
1718
};
1819

20+
const modifierCode = (shift?: boolean, altRight?: boolean) => {
21+
return (shift ? modifiers["ShiftLeft"] : 0)
22+
| (altRight ? modifiers["AltRight"] : 0)
23+
}
24+
const noModifier = 0
25+
1926
export default function PasteModal() {
2027
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
2128
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
@@ -27,6 +34,18 @@ export default function PasteModal() {
2734
const [invalidChars, setInvalidChars] = useState<string[]>([]);
2835
const close = useClose();
2936

37+
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
38+
const setKeyboardLayout = useSettingsStore(
39+
state => state.setKeyboardLayout,
40+
);
41+
42+
useEffect(() => {
43+
send("getKeyboardLayout", {}, resp => {
44+
if ("error" in resp) return;
45+
setKeyboardLayout(resp.result as string);
46+
});
47+
}, [send, setKeyboardLayout]);
48+
3049
const onCancelPasteMode = useCallback(() => {
3150
setPasteMode(false);
3251
setDisableVideoFocusTrap(false);
@@ -37,33 +56,49 @@ export default function PasteModal() {
3756
setPasteMode(false);
3857
setDisableVideoFocusTrap(false);
3958
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
59+
if (!keyboardLayout) return;
60+
if (!chars[keyboardLayout]) return;
4061

4162
const text = TextAreaRef.current.value;
4263

4364
try {
4465
for (const char of text) {
45-
const { key, shift } = chars[char] ?? {};
66+
const { key, shift, altRight, deadKey, accentKey } = chars[keyboardLayout][char]
4667
if (!key) continue;
4768

48-
await new Promise<void>((resolve, reject) => {
49-
send(
50-
"keyboardReport",
51-
hidKeyboardPayload([keys[key]], shift ? modifiers["ShiftLeft"] : 0),
52-
params => {
53-
if ("error" in params) return reject(params.error);
54-
send("keyboardReport", hidKeyboardPayload([], 0), params => {
69+
const keyz = [ keys[key] ];
70+
const modz = [ modifierCode(shift, altRight) ];
71+
72+
if (deadKey) {
73+
keyz.push(keys["Space"]);
74+
modz.push(noModifier);
75+
}
76+
if (accentKey) {
77+
keyz.unshift(keys[accentKey.key])
78+
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
79+
}
80+
81+
for (const [index, kei] of keyz.entries()) {
82+
await new Promise<void>((resolve, reject) => {
83+
send(
84+
"keyboardReport",
85+
hidKeyboardPayload([kei], modz[index]),
86+
params => {
5587
if ("error" in params) return reject(params.error);
56-
resolve();
57-
});
58-
},
59-
);
60-
});
88+
send("keyboardReport", hidKeyboardPayload([], 0), params => {
89+
if ("error" in params) return reject(params.error);
90+
resolve();
91+
});
92+
},
93+
);
94+
});
95+
}
6196
}
6297
} catch (error) {
6398
console.error(error);
6499
notifications.error("Failed to paste text");
65100
}
66-
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
101+
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, keyboardLayout]);
67102

68103
useEffect(() => {
69104
if (TextAreaRef.current) {
@@ -113,7 +148,7 @@ export default function PasteModal() {
113148
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
114149
[...new Intl.Segmenter().segment(value)]
115150
.map(x => x.segment)
116-
.filter(char => !chars[char]),
151+
.filter(char => !chars[keyboardLayout][char]),
117152
),
118153
];
119154

@@ -132,6 +167,11 @@ export default function PasteModal() {
132167
)}
133168
</div>
134169
</div>
170+
<div className="space-y-4">
171+
<p className="text-xs text-slate-600 dark:text-slate-400">
172+
Sending text using keyboard layout: {layouts[keyboardLayout]}
173+
</p>
174+
</div>
135175
</div>
136176
</div>
137177
</div>

ui/src/hooks/stores.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,9 @@ interface SettingsState {
302302

303303
backlightSettings: BacklightSettings;
304304
setBacklightSettings: (settings: BacklightSettings) => void;
305+
306+
keyboardLayout: string;
307+
setKeyboardLayout: (layout: string) => void;
305308
}
306309

307310
export const useSettingsStore = create(
@@ -330,6 +333,9 @@ export const useSettingsStore = create(
330333
},
331334
setBacklightSettings: (settings: BacklightSettings) =>
332335
set({ backlightSettings: settings }),
336+
337+
keyboardLayout: "en-US",
338+
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
333339
}),
334340
{
335341
name: "settings",

ui/src/keyboardLayouts.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE"
2+
import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ"
3+
import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK"
4+
import { chars as chars_en_US, name as name_en_US } from "@/keyboardLayouts/en_US"
5+
import { chars as chars_fr_FR, name as name_fr_FR } from "@/keyboardLayouts/fr_FR"
6+
import { chars as chars_de_DE, name as name_de_DE } from "@/keyboardLayouts/de_DE"
7+
import { chars as chars_it_IT, name as name_it_IT } from "@/keyboardLayouts/it_IT"
8+
import { chars as chars_nb_NO, name as name_nb_NO } from "@/keyboardLayouts/nb_NO"
9+
import { chars as chars_es_ES, name as name_es_ES } from "@/keyboardLayouts/es_ES"
10+
import { chars as chars_sv_SE, name as name_sv_SE } from "@/keyboardLayouts/sv_SE"
11+
import { chars as chars_fr_CH, name as name_fr_CH } from "@/keyboardLayouts/fr_CH"
12+
import { chars as chars_de_CH, name as name_de_CH } from "@/keyboardLayouts/de_CH"
13+
14+
type KeyInfo = { key: string | number; shift?: boolean, altRight?: boolean }
15+
export type KeyCombo = KeyInfo & { deadKey?: boolean, accentKey?: KeyInfo }
16+
17+
export const layouts: Record<string, string> = {
18+
be_FR: name_fr_BE,
19+
cs_CZ: name_cs_CZ,
20+
en_UK: name_en_UK,
21+
en_US: name_en_US,
22+
fr_FR: name_fr_FR,
23+
de_DE: name_de_DE,
24+
it_IT: name_it_IT,
25+
nb_NO: name_nb_NO,
26+
es_ES: name_es_ES,
27+
sv_SE: name_sv_SE,
28+
fr_CH: name_fr_CH,
29+
de_CH: name_de_CH,
30+
}
31+
32+
export const chars: Record<string, Record<string, KeyCombo>> = {
33+
be_FR: chars_fr_BE,
34+
cs_CZ: chars_cs_CZ,
35+
en_UK: chars_en_UK,
36+
en_US: chars_en_US,
37+
fr_FR: chars_fr_FR,
38+
de_DE: chars_de_DE,
39+
it_IT: chars_it_IT,
40+
nb_NO: chars_nb_NO,
41+
es_ES: chars_es_ES,
42+
sv_SE: chars_sv_SE,
43+
fr_CH: chars_fr_CH,
44+
de_CH: chars_de_CH,
45+
};

0 commit comments

Comments
 (0)