From 9bba452185607df0f4b5be3b1474eb85e77480aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Sun, 16 Aug 2026 16:21:42 +0200 Subject: [PATCH] feat: add Key language setting for non-US scancode labels Let Agent show UK, German, and Nordic key legends on the keymap and in scancode dropdowns without changing UHK user configuration (#2147). Co-authored-by: Cursor --- .../config-serializer/config-items/index.ts | 1 + .../config-items/scancode-labels.test.ts | 63 +++++ .../config-items/scancode-labels.ts | 267 ++++++++++++++++++ .../src/models/application-settings.ts | 6 + packages/uhk-common/src/models/index.ts | 1 + .../uhk-common/src/models/key-language.ts | 15 + .../agent/settings/settings.component.html | 36 ++- .../agent/settings/settings.component.ts | 11 +- .../macro/edit/macro-edit.component.ts | 2 + .../macro/item/macro-item.component.ts | 25 +- .../tab/keypress/keypress-tab.component.html | 4 +- .../tab/keypress/keypress-tab.component.ts | 86 ++++-- .../svg-keyboard-key.component.ts | 12 +- .../svg-keystroke-key.component.ts | 24 +- .../src/app/services/mapper.service.ts | 161 ++--------- packages/uhk-web/src/app/store/actions/app.ts | 11 +- packages/uhk-web/src/app/store/effects/app.ts | 1 + packages/uhk-web/src/app/store/index.ts | 2 + .../src/app/store/reducers/app.reducer.ts | 12 + 19 files changed, 569 insertions(+), 171 deletions(-) create mode 100644 packages/uhk-common/src/config-serializer/config-items/scancode-labels.test.ts create mode 100644 packages/uhk-common/src/config-serializer/config-items/scancode-labels.ts create mode 100644 packages/uhk-common/src/models/key-language.ts diff --git a/packages/uhk-common/src/config-serializer/config-items/index.ts b/packages/uhk-common/src/config-serializer/config-items/index.ts index aa57ae6faa2..08360b0ad37 100644 --- a/packages/uhk-common/src/config-serializer/config-items/index.ts +++ b/packages/uhk-common/src/config-serializer/config-items/index.ts @@ -1,4 +1,5 @@ export { default as SCANCODES } from './scancodes.js'; +export * from './scancode-labels.js'; export * from './advanced-secondary-role-configuration.js'; export * from './backlighting-mode.js'; diff --git a/packages/uhk-common/src/config-serializer/config-items/scancode-labels.test.ts b/packages/uhk-common/src/config-serializer/config-items/scancode-labels.test.ts new file mode 100644 index 00000000000..e17274bb646 --- /dev/null +++ b/packages/uhk-common/src/config-serializer/config-items/scancode-labels.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from 'node:test'; + +import { KeyLanguage } from '../../models/key-language.js'; +import { + formatScancodeDropdownText, + getBasicScancodeTexts, + getScancodesForKeyLanguage +} from './scancode-labels.js'; + +describe('scancode-labels', () => { + it('returns US labels by default', ({ assert }) => { + assert.deepStrictEqual(getBasicScancodeTexts(51), [';', ':']); + assert.deepStrictEqual(getBasicScancodeTexts(28, KeyLanguage.Us), ['Y']); + }); + + it('applies German QWERTZ and umlaut overrides', ({ assert }) => { + assert.deepStrictEqual(getBasicScancodeTexts(28, KeyLanguage.German), ['Z']); + assert.deepStrictEqual(getBasicScancodeTexts(29, KeyLanguage.German), ['Y']); + assert.deepStrictEqual(getBasicScancodeTexts(51, KeyLanguage.German), ['Ö']); + assert.deepStrictEqual(getBasicScancodeTexts(52, KeyLanguage.German), ['Ä']); + assert.deepStrictEqual(getBasicScancodeTexts(47, KeyLanguage.German), ['Ü']); + assert.deepStrictEqual(getBasicScancodeTexts(45, KeyLanguage.German), ['ß', '?']); + }); + + it('applies UK punctuation overrides', ({ assert }) => { + assert.deepStrictEqual(getBasicScancodeTexts(31, KeyLanguage.Uk), ['2', '"']); + assert.deepStrictEqual(getBasicScancodeTexts(32, KeyLanguage.Uk), ['3', '£']); + assert.deepStrictEqual(getBasicScancodeTexts(52, KeyLanguage.Uk), ['\'', '@']); + assert.deepStrictEqual(getBasicScancodeTexts(50, KeyLanguage.Uk), ['#', '~']); + }); + + it('applies Nordic letter and punctuation overrides', ({ assert }) => { + assert.deepStrictEqual(getBasicScancodeTexts(47, KeyLanguage.Nordic), ['Å']); + assert.deepStrictEqual(getBasicScancodeTexts(51, KeyLanguage.Nordic), ['Ö', 'Ø']); + assert.deepStrictEqual(getBasicScancodeTexts(52, KeyLanguage.Nordic), ['Ä', 'Æ']); + assert.deepStrictEqual(getBasicScancodeTexts(53, KeyLanguage.Nordic), ['§', '½']); + }); + + it('formats dropdown text without icon tokens', ({ assert }) => { + assert.strictEqual(formatScancodeDropdownText(['Np 2', 'icon-kbd__mod--arrow-down']), 'Np 2'); + assert.strictEqual(formatScancodeDropdownText(['Ö', 'Ø']), 'Ö Ø'); + }); + + it('localizes scancode dropdown groups for German', ({ assert }) => { + const groups = getScancodesForKeyLanguage(KeyLanguage.German); + const letterGroup = groups.find(group => group.text === 'Letter'); + const punctuationGroup = groups.find(group => group.text === 'Punctuation'); + + const yKey = letterGroup?.children.find(child => child.id === '28'); + const semicolonKey = punctuationGroup?.children.find(child => child.id === '51'); + + assert.strictEqual(yKey?.text, 'Z'); + assert.strictEqual(semicolonKey?.text, 'Ö'); + }); + + it('keeps media labels unchanged for non-US languages', ({ assert }) => { + const groups = getScancodesForKeyLanguage(KeyLanguage.Nordic); + const mediaGroup = groups.find(group => group.text === 'Media'); + const mute = mediaGroup?.children.find(child => child.id === '127'); + + assert.strictEqual(mute?.text, 'Mute'); + }); +}); diff --git a/packages/uhk-common/src/config-serializer/config-items/scancode-labels.ts b/packages/uhk-common/src/config-serializer/config-items/scancode-labels.ts new file mode 100644 index 00000000000..b33453853e0 --- /dev/null +++ b/packages/uhk-common/src/config-serializer/config-items/scancode-labels.ts @@ -0,0 +1,267 @@ +import { KeyLanguage } from '../../models/key-language.js'; +import SCANCODES from './scancodes.js'; + +export type ScancodeTexts = string[]; + +export interface ScancodeOptionChild { + id: string; + text: string; + additional?: { + type?: string; + scancode?: number; + explanation?: string; + }; +} + +export interface ScancodeOptionGroup { + text: string; + children: ScancodeOptionChild[]; +} + +/** + * US HID usage → label lines for keymap SVG rendering (unshifted [, shifted]). + * Matches the previous MapperService US map. + */ +export const US_BASIC_SCANCODE_TEXTS: Readonly> = { + 4: ['A'], + 5: ['B'], + 6: ['C'], + 7: ['D'], + 8: ['E'], + 9: ['F'], + 10: ['G'], + 11: ['H'], + 12: ['I'], + 13: ['J'], + 14: ['K'], + 15: ['L'], + 16: ['M'], + 17: ['N'], + 18: ['O'], + 19: ['P'], + 20: ['Q'], + 21: ['R'], + 22: ['S'], + 23: ['T'], + 24: ['U'], + 25: ['V'], + 26: ['W'], + 27: ['X'], + 28: ['Y'], + 29: ['Z'], + 30: ['1', '!'], + 31: ['2', '@'], + 32: ['3', '#'], + 33: ['4', '$'], + 34: ['5', '%'], + 35: ['6', '^'], + 36: ['7', '&'], + 37: ['8', '*'], + 38: ['9', '('], + 39: ['0', ')'], + 40: ['Enter'], + 41: ['Esc'], + 42: ['Backspace'], + 43: ['Tab'], + 44: ['Space'], + 45: ['-', '_'], + 46: ['=', '+'], + 47: ['[', '{'], + 48: [']', '}'], + 49: ['\\', '|'], + 50: ['ISO key', '#'], + 51: [';', ':'], + 52: ['\'', '"'], + 53: ['`', '~'], + 54: [',', '<'], + 55: ['.', '>'], + 56: ['/', '?'], + 57: ['Caps Lock'], + 58: ['F1'], + 59: ['F2'], + 60: ['F3'], + 61: ['F4'], + 62: ['F5'], + 63: ['F6'], + 64: ['F7'], + 65: ['F8'], + 66: ['F9'], + 67: ['F10'], + 68: ['F11'], + 69: ['F12'], + 70: ['PrtScn', 'SysRq'], + 71: ['ScrLk'], + 72: ['Pause'], + 73: ['Insert'], + 74: ['Home'], + 75: ['PgUp'], + 76: ['Del'], + 77: ['End'], + 78: ['PgDn'], + 79: ['Right Arrow'], + 80: ['Left Arrow'], + 81: ['Down Arrow'], + 82: ['Up Arrow'], + 83: ['NumLk'], + 84: ['Np /'], + 85: ['Np *'], + 86: ['Np -'], + 87: ['Np +'], + 88: ['Np Enter'], + 89: ['Np 1', 'End'], + 90: ['Np 2', 'icon-kbd__mod--arrow-down'], + 91: ['Np 3', 'PgDn'], + 92: ['Np 4', 'icon-kbd__mod--arrow-left'], + 93: ['Np 5'], + 94: ['Np 6', 'icon-kbd__mod--arrow-right'], + 95: ['Np 7', 'Home'], + 96: ['Np 8', 'icon-kbd__mod--arrow-up'], + 97: ['Np 9', 'PgUp'], + 98: ['Np 0', 'Insert'], + 99: ['Np .', 'Del'], + 100: ['ISO key', '|'], + 101: ['Menu'], + 104: ['F13'], + 105: ['F14'], + 106: ['F15'], + 107: ['F16'], + 108: ['F17'], + 109: ['F18'], + 110: ['F19'], + 111: ['F20'], + 112: ['F21'], + 113: ['F22'], + 114: ['F23'], + 115: ['F24'], + 135: ['Int1'], + 136: ['Int2'], + 137: ['Int3'], + 138: ['Int4'], + 139: ['Int5'], + 144: ['Lang1'], + 145: ['Lang2'], + 176: ['00'], + 177: ['000'] +}; + +/** + * Sparse overrides matching UHK UK / German / Nordic keycap legends (best effort). + * Only unshifted/shifted top legends — not AltGr / side-printed Mod/Fn legends. + */ +export const KEY_LANGUAGE_OVERRIDES: Readonly>>> = { + [KeyLanguage.Us]: {}, + [KeyLanguage.Uk]: { + 31: ['2', '"'], + 32: ['3', '£'], + 50: ['#', '~'], + 52: ['\'', '@'], + 53: ['`', '¬'], + 100: ['\\', '|'] + }, + [KeyLanguage.German]: { + 28: ['Z'], + 29: ['Y'], + 31: ['2', '"'], + 32: ['3', '§'], + 35: ['6', '&'], + 36: ['7', '/'], + 37: ['8', '('], + 38: ['9', ')'], + 39: ['0', '='], + 45: ['ß', '?'], + 46: ['´', '`'], + 47: ['Ü'], + 48: ['+', '*'], + 50: ['#', '\''], + 51: ['Ö'], + 52: ['Ä'], + 53: ['^', '°'], + 54: [',', ';'], + 55: ['.', ':'], + 56: ['-', '_'], + 100: ['<', '>'] + }, + [KeyLanguage.Nordic]: { + 31: ['2', '"'], + 32: ['3', '#'], + 33: ['4', '¤'], + 35: ['6', '&'], + 36: ['7', '/'], + 37: ['8', '('], + 38: ['9', ')'], + 39: ['0', '='], + 45: ['+', '?'], + 46: ['´', '`'], + 47: ['Å'], + 48: ['¨', '^'], + 50: ['*', '\''], + 51: ['Ö', 'Ø'], + 52: ['Ä', 'Æ'], + 53: ['§', '½'], + 54: [',', ';'], + 55: ['.', ':'], + 56: ['-', '_'], + 100: ['<', '>'] + } +}; + +export function getBasicScancodeTexts(scancode: number, language: KeyLanguage = KeyLanguage.Us): ScancodeTexts | undefined { + const override = KEY_LANGUAGE_OVERRIDES[language]?.[scancode]; + if (override) { + return override; + } + + return US_BASIC_SCANCODE_TEXTS[scancode]; +} + +export function formatScancodeDropdownText(texts: ScancodeTexts): string { + return texts + .filter(text => !text.startsWith('icon-')) + .join(' '); +} + +function getChildBasicScancode(child: ScancodeOptionChild): number | undefined { + if (child.additional?.type && child.additional.type !== 'basic') { + return undefined; + } + + if (child.additional?.scancode != null) { + return child.additional.scancode; + } + + return Number.parseInt(child.id, 10); +} + +/** + * Returns scancode dropdown groups with labels for the given key language. + * US keeps the existing SCANCODES wording; other languages apply legend overrides. + */ +export function getScancodesForKeyLanguage(language: KeyLanguage): ScancodeOptionGroup[] { + const groups = SCANCODES as ScancodeOptionGroup[]; + + if (language === KeyLanguage.Us) { + return groups; + } + + const overrides = KEY_LANGUAGE_OVERRIDES[language]; + + return groups.map(group => ({ + ...group, + children: group.children.map(child => { + const scancode = getChildBasicScancode(child); + if (scancode == null) { + return child; + } + + const texts = overrides[scancode]; + if (!texts) { + return child; + } + + return { + ...child, + text: formatScancodeDropdownText(texts) + }; + }) + })); +} diff --git a/packages/uhk-common/src/models/application-settings.ts b/packages/uhk-common/src/models/application-settings.ts index 5b50faf120f..aec6cf747fa 100644 --- a/packages/uhk-common/src/models/application-settings.ts +++ b/packages/uhk-common/src/models/application-settings.ts @@ -1,3 +1,4 @@ +import { KeyLanguage } from './key-language.js'; import { MacroGroupingSettings } from './macro-grouping-settings.js'; import { RgbColorInterface } from './rgb-color-interface.js'; @@ -16,6 +17,11 @@ export interface ApplicationSettings { everAttemptedSavingToKeyboard: boolean; animationEnabled?: boolean; appTheme?: AppTheme; + /** + * Scancode-to-symbol mapping used for keymap labels and scancode dropdowns. + * Agent-only setting; not part of the UHK user configuration. + */ + keyLanguage?: KeyLanguage; backlightingColorPalette?: Array /** * If true, the keyboard halves are joined together in the UI independently of the actual keyboard state. diff --git a/packages/uhk-common/src/models/index.ts b/packages/uhk-common/src/models/index.ts index 923b5a78e3f..53a89f2e95a 100644 --- a/packages/uhk-common/src/models/index.ts +++ b/packages/uhk-common/src/models/index.ts @@ -19,6 +19,7 @@ export * from './notification.js'; export * from './protocol-versions.js'; export * from './init-backlighting-color-palette.js'; export * from './ipc-response.js'; +export * from './key-language.js'; export * from './keyboard-layout.enum.js'; export * from './left-slot-modules.js'; export * from './app-start-info.js'; diff --git a/packages/uhk-common/src/models/key-language.ts b/packages/uhk-common/src/models/key-language.ts new file mode 100644 index 00000000000..dfd40641311 --- /dev/null +++ b/packages/uhk-common/src/models/key-language.ts @@ -0,0 +1,15 @@ +export enum KeyLanguage { + Us = 'us', + Uk = 'uk', + German = 'german', + Nordic = 'nordic' +} + +export const DEFAULT_KEY_LANGUAGE = KeyLanguage.Us; + +export const KEY_LANGUAGE_OPTIONS: ReadonlyArray<{ id: KeyLanguage; text: string }> = [ + { id: KeyLanguage.Us, text: 'US' }, + { id: KeyLanguage.Uk, text: 'UK' }, + { id: KeyLanguage.German, text: 'German' }, + { id: KeyLanguage.Nordic, text: 'Nordic' } +]; diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.html b/packages/uhk-web/src/app/components/agent/settings/settings.component.html index efaac75dcfb..e20014c9e46 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.html +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.html @@ -62,7 +62,33 @@

- + +
+ +
+ +
+
+ +
+
+
Macro sidebar grouping

+ +

Controls scancode labels on the keymap and in scancode dropdowns. This is an Agent setting and is not saved to the keyboard. Choose the layout that matches your keycaps (US, UK, German, or Nordic).

+
+ -

The Follow operating system theme option may not be supported on all Linux distributions.

+

The Follow operating system theme option may not be supported on all Linux distributions.

-

On Linux, disabling this option stops minimize-to-tray behaviour immediately, but the tray icon may remain visible until Agent is restarted.

+

On Linux, disabling this option stops minimize-to-tray behaviour immediately, but the tray icon may remain visible until Agent is restarted.

diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.ts b/packages/uhk-web/src/app/components/agent/settings/settings.component.ts index ee104548631..317cbe06917 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.ts +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.ts @@ -5,7 +5,7 @@ import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { AppTheme, MacroGroupingSettings } from 'uhk-common'; +import { AppTheme, KEY_LANGUAGE_OPTIONS, KeyLanguage, MacroGroupingSettings } from 'uhk-common'; import { AppState, appUpdateSettingsState, @@ -13,6 +13,7 @@ import { getAnimationEnabled, getAppTheme, getIsAdvancedSettingsMenuVisible, + getKeyLanguage, getMacroGroupingSettings, getMinimizeToTray, getOperatingSystem, @@ -27,6 +28,7 @@ import { import { OpenConfigFolderAction, SetAppThemeAction, + SetKeyLanguageAction, SetMacroGroupingSettingsAction, ToggleAnimationEnabledAction, ToggleKeyboardHalvesAlwaysJoinedAction, @@ -55,6 +57,7 @@ export class SettingsComponent { animationEnabled$: Observable; minimizeToTray$: Observable; appTheme$: Observable; + keyLanguage$: Observable; isLinux$: Observable; faCog = faCog; keyboardHalvesAlwaysJoined$: Observable; @@ -62,6 +65,7 @@ export class SettingsComponent { alwaysEnableAdvancedModeSettingVisible$: Observable; macroGroupingSettings$: Observable; macroGroupingMaxDepth = MACRO_GROUPING_MAX_DEPTH; + keyLanguages = KEY_LANGUAGE_OPTIONS; themes: ThemeOption[] = [ { id: AppTheme.System, text: 'Follow operating system theme', icon: faDesktop }, { id: AppTheme.Light, text: 'Light', icon: faSun }, @@ -75,6 +79,7 @@ export class SettingsComponent { this.animationEnabled$ = this.store.select(getAnimationEnabled); this.minimizeToTray$ = this.store.select(getMinimizeToTray); this.appTheme$ = this.store.select(getAppTheme); + this.keyLanguage$ = this.store.select(getKeyLanguage); this.isLinux$ = this.store.select(getOperatingSystem).pipe(map(os => os === OperatingSystem.Linux)); this.keyboardHalvesAlwaysJoined$ = this.store.select(keyboardHalvesAlwaysJoined); this.alwaysEnableAdvancedMode$ = this.store.select(getAlwaysEnableAdvancedMode); @@ -102,6 +107,10 @@ export class SettingsComponent { this.store.dispatch(new SetAppThemeAction(value)); } + selectKeyLanguage(value: KeyLanguage) { + this.store.dispatch(new SetKeyLanguageAction(value)); + } + toggleKeyboardHalvesAlwaysJoined(enabled: boolean): void { this.store.dispatch(new ToggleKeyboardHalvesAlwaysJoinedAction(enabled)); } diff --git a/packages/uhk-web/src/app/components/macro/edit/macro-edit.component.ts b/packages/uhk-web/src/app/components/macro/edit/macro-edit.component.ts index 04f0ad6d997..5b4a661419d 100644 --- a/packages/uhk-web/src/app/components/macro/edit/macro-edit.component.ts +++ b/packages/uhk-web/src/app/components/macro/edit/macro-edit.component.ts @@ -18,6 +18,7 @@ import { MapperService } from '../../../services/mapper.service'; import { AppState, getDefaultUserConfiguration, + getKeyLanguage, getKeymaps, getSelectedMacro, getSelectedMacroAction, @@ -82,6 +83,7 @@ export class MacroEditComponent implements OnDestroy { this.store.select(getSelectedMacro), this.store.select(getKeymaps), this.store.select(getDefaultUserConfiguration), + this.store.select(getKeyLanguage), ]).subscribe(([macro, keymaps, defaultUserConfiguration]) => { this.macro = macro; this.assignments = macro diff --git a/packages/uhk-web/src/app/components/macro/item/macro-item.component.ts b/packages/uhk-web/src/app/components/macro/item/macro-item.component.ts index 53d5a72ce54..9ba34ca4fd8 100644 --- a/packages/uhk-web/src/app/components/macro/item/macro-item.component.ts +++ b/packages/uhk-web/src/app/components/macro/item/macro-item.component.ts @@ -1,9 +1,11 @@ import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, + OnDestroy, OnInit, Output, SimpleChanges, @@ -11,6 +13,8 @@ import { } from '@angular/core'; import { animate, style, transition, trigger } from '@angular/animations'; import { faCode, faGripLinesVertical } from '@fortawesome/free-solid-svg-icons'; +import { Store } from '@ngrx/store'; +import { Subscription } from 'rxjs'; import { CommandMacroAction, DelayMacroAction, @@ -24,8 +28,9 @@ import { TextMacroAction } from 'uhk-common'; -import { MapperService } from '../../../services/mapper.service'; import { SelectedMacroActionId, SelectedMacroItem, TabName } from '../../../models'; +import { MapperService } from '../../../services/mapper.service'; +import { AppState, getKeyLanguage } from '../../../store'; @Component({ animations: [ @@ -65,7 +70,7 @@ import { SelectedMacroActionId, SelectedMacroItem, TabName } from '../../../mode styleUrls: ['./macro-item.component.scss'], host: { 'class': 'macro-item' } }) -export class MacroItemComponent implements OnInit, OnChanges { +export class MacroItemComponent implements OnInit, OnChanges, OnDestroy { @Input() macroAction: MacroAction; @Input() editable: boolean; @Input() editing: boolean; @@ -90,7 +95,19 @@ export class MacroItemComponent implements OnInit, OnChanges { faGripLinesVertical = faGripLinesVertical; isCommand = false; + private readonly cdRef = inject(ChangeDetectorRef); private readonly mapper = inject(MapperService); + private readonly store = inject>(Store); + private readonly subscriptions = new Subscription(); + + constructor() { + this.subscriptions.add( + this.store.select(getKeyLanguage).subscribe(() => { + this.updateView(); + this.cdRef.markForCheck(); + }) + ); + } ngOnInit() { this.updateView(); @@ -105,6 +122,10 @@ export class MacroItemComponent implements OnInit, OnChanges { } } + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + saveEditedAction(editedAction: MacroAction): void { this.macroAction = editedAction; this.updateView(); diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html index 65f1190a2f1..a94ac3d2a3c 100644 --- a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html +++ b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html @@ -11,8 +11,8 @@ > -

Labels are shown according to en-US character-to-scancode mapping. This means that output may differ from the label if your computer uses layout different from en-US. In such case, you need to pick the character of the desired key according to the en-US layout.

-

Let's say you're a German user and want to map the Ö character. You can see that on US keyboards this is the semicolon key, so choose semicolon in this dropdown.

+

Labels follow the Key language setting on the Agent Settings page (US, UK, German, or Nordic). The UHK still sends HID scancodes, so output depends on the OS keyboard layout.

+

If the character you want is missing from your Key language mapping, pick the matching physical key by its scancode label, or use a custom scancode.

Click to learn more about mappings, scancodes, and custom scancodes.

>(Store); + private readonly subscriptions = new Subscription(); constructor() { super(); this.leftModifiers = this.mapper.getLeftKeyModifiers(); this.rightModifiers = this.mapper.getRightKeyModifiers(); - - this.scanCodeGroups = [{ - id: '0', - text: 'None', - additional: { - type: 'basic', - scancode: 0 - } - }]; - SCANCODES.forEach(group => { - group.children.forEach(child => { - this.scanCodeGroups.push({ - id: child.id, - text: child.text, - group: group.text, - additional: { - type: 'basic', - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - scancode: Number.parseInt(child.id, 10), - ...child.additional - } - }); - }); - }); - this.selectedScancodeOption = this.scanCodeGroups[0]; this.selectedSecondaryRoleIndex = -1; + this.buildScanCodeGroups(KeyLanguage.Us); + this.selectedScancodeOption = this.scanCodeGroups[0]; + + this.subscriptions.add( + this.store.select(getKeyLanguage).subscribe(keyLanguage => { + const selectedId = this.selectedScancodeOption?.id; + this.buildScanCodeGroups(keyLanguage); + this.selectedScancodeOption = this.scanCodeGroups.find(option => option.id === selectedId) + || this.scanCodeGroups[0]; + this.cdRef.markForCheck(); + }) + ); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); } ngOnChanges(changes: SimpleChanges) { @@ -405,6 +410,33 @@ export class KeypressTabComponent extends Tab implements OnChanges { text: this.mapper.getSecondaryRoleText(action) }; } + + private buildScanCodeGroups(keyLanguage: KeyLanguage): void { + this.scanCodeGroups = [{ + id: '0', + text: 'None', + additional: { + type: 'basic', + scancode: 0 + } + }]; + + getScancodesForKeyLanguage(keyLanguage).forEach(group => { + group.children.forEach(child => { + this.scanCodeGroups.push({ + id: child.id, + text: child.text, + group: group.text, + additional: { + type: 'basic', + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + scancode: Number.parseInt(child.id, 10), + ...child.additional + } + }); + }); + }); + } } const mediaRegExp = new RegExp('^M([0-9]*)', 'i'); diff --git a/packages/uhk-web/src/app/components/svg/keys/svg-keyboard-key/svg-keyboard-key.component.ts b/packages/uhk-web/src/app/components/svg/keys/svg-keyboard-key/svg-keyboard-key.component.ts index 371aca5448e..325e44f2b92 100644 --- a/packages/uhk-web/src/app/components/svg/keys/svg-keyboard-key/svg-keyboard-key.component.ts +++ b/packages/uhk-web/src/app/components/svg/keys/svg-keyboard-key/svg-keyboard-key.component.ts @@ -45,7 +45,7 @@ import { CaptureService } from '../../../../services/capture.service'; import { KeyActionColoringService } from '../../../../services/key-action-coloring.service'; import { MapperService } from '../../../../services/mapper.service'; -import { AppState } from '../../../../store'; +import { AppState, getKeyLanguage } from '../../../../store'; import { initLayerOptions } from '../../../../store/reducers/layer-options'; import { SvgKeyCaptureEvent, SvgKeyClickEvent } from '../../../../models/svg-key-events'; import { OperatingSystem } from '../../../../models/operating-system'; @@ -150,6 +150,16 @@ export class SvgKeyboardKeyComponent implements OnChanges, OnDestroy { private isFocused = false; private readonly sanitizer = inject(DomSanitizer); private readonly store = inject>(Store); + private readonly cdRef = inject(ChangeDetectorRef); + + constructor() { + this.subscriptions.add( + this.store.select(getKeyLanguage).subscribe(() => { + this.setLabels(); + this.cdRef.markForCheck(); + }) + ); + } @HostBinding('@blink') get blinkAnimationBinding() { diff --git a/packages/uhk-web/src/app/components/svg/keys/svg-keystroke-key/svg-keystroke-key.component.ts b/packages/uhk-web/src/app/components/svg/keys/svg-keystroke-key/svg-keystroke-key.component.ts index 577dca8e480..5918f55c9ba 100644 --- a/packages/uhk-web/src/app/components/svg/keys/svg-keystroke-key/svg-keystroke-key.component.ts +++ b/packages/uhk-web/src/app/components/svg/keys/svg-keystroke-key/svg-keystroke-key.component.ts @@ -1,9 +1,12 @@ -import { Component, Input, OnChanges, ChangeDetectionStrategy, inject } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Subscription } from 'rxjs'; import { KeyModifiers, KeystrokeAction } from 'uhk-common'; import { MapperService } from '../../../../services/mapper.service'; -import { isRectangleAsSecondaryRoleKey } from '../util'; +import { AppState, getKeyLanguage } from '../../../../store'; import { SECONDARY_ROLE_BOTTOM_MARGIN } from '../../constants'; +import { isRectangleAsSecondaryRoleKey } from '../util'; class SvgAttributes { width: number; @@ -28,7 +31,7 @@ class SvgAttributes { styleUrls: ['./svg-keystroke-key.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class SvgKeystrokeKeyComponent implements OnChanges { +export class SvgKeystrokeKeyComponent implements OnChanges, OnDestroy { @Input() height: number; @Input() width: number; @Input() keystrokeAction: KeystrokeAction; @@ -58,7 +61,10 @@ export class SvgKeystrokeKeyComponent implements OnChanges { thisSecondaryRoleText: string; subComponentSecondaryRoleText: string; + private readonly cdRef = inject(ChangeDetectorRef); private readonly mapper = inject(MapperService); + private readonly store = inject>(Store); + private readonly subscriptions = new Subscription(); constructor() { this.modifierIconNames = {}; @@ -68,12 +74,24 @@ export class SvgKeystrokeKeyComponent implements OnChanges { this.control = new SvgAttributes(); this.option = new SvgAttributes(); this.command = new SvgAttributes(); + this.subscriptions.add( + this.store.select(getKeyLanguage).subscribe(() => { + if (this.keystrokeAction) { + this.calculatePositions(); + this.cdRef.markForCheck(); + } + }) + ); } ngOnChanges() { this.calculatePositions(); } + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + private calculatePositions(): void { let textYModifier = 0; let secondaryYModifier = 0; diff --git a/packages/uhk-web/src/app/services/mapper.service.ts b/packages/uhk-web/src/app/services/mapper.service.ts index a60d4e61265..04af4d24c33 100644 --- a/packages/uhk-web/src/app/services/mapper.service.ts +++ b/packages/uhk-web/src/app/services/mapper.service.ts @@ -1,9 +1,9 @@ import { Injectable, OnDestroy, inject } from '@angular/core'; import { Store } from '@ngrx/store'; -import { KeyModifiers, KeystrokeType, SecondaryRoleAction } from 'uhk-common'; -import { Subscription } from 'rxjs'; +import { combineLatest, Subscription } from 'rxjs'; +import { getBasicScancodeTexts, KeyLanguage, KeyModifiers, KeystrokeType, SecondaryRoleAction, US_BASIC_SCANCODE_TEXTS } from 'uhk-common'; -import { AppState, getOperatingSystem } from '../store'; +import { AppState, getKeyLanguage, getOperatingSystem } from '../store'; import { OperatingSystem } from '../models/operating-system'; import { KeyModifierModel } from '../models/key-modifier-model'; @@ -28,27 +28,34 @@ export class MapperService implements OnDestroy { private secondaryRoleTexts: Map; private operatingSystem: OperatingSystem; + private keyLanguage: KeyLanguage = KeyLanguage.Us; private osSubscription: Subscription; private readonly store = inject>(Store); constructor() { - this.osSubscription = this.store - .select(getOperatingSystem) - .subscribe(os => { - this.operatingSystem = os; - this.initOsSpecificText(); - this.initScanCodeTextMap(); - this.initScancodeIcons(); - this.initNameToFileNames(); - this.initSecondaryRoleTexts(); - }); + this.osSubscription = combineLatest([ + this.store.select(getOperatingSystem), + this.store.select(getKeyLanguage), + ]).subscribe(([os, keyLanguage]) => { + this.operatingSystem = os; + this.keyLanguage = keyLanguage; + this.initOsSpecificText(); + this.initScanCodeTextMap(); + this.initScancodeIcons(); + this.initNameToFileNames(); + this.initSecondaryRoleTexts(); + }); } ngOnDestroy(): void { this.osSubscription.unsubscribe(); } + public getKeyLanguage(): KeyLanguage { + return this.keyLanguage; + } + public scanCodeToText(scanCode: number, type: KeystrokeType = KeystrokeType.basic): string[] { let map: Map; let prefix: string; @@ -210,128 +217,20 @@ export class MapperService implements OnDestroy { } } - // TODO: read the mapping from JSON + // Label map is built from uhk-common scancode-labels + OS-specific Enter naming. private initScanCodeTextMap(): void { this.basicScanCodeTextMap = new Map(); - this.basicScanCodeTextMap.set(4, ['A']); - this.basicScanCodeTextMap.set(5, ['B']); - this.basicScanCodeTextMap.set(6, ['C']); - this.basicScanCodeTextMap.set(7, ['D']); - this.basicScanCodeTextMap.set(8, ['E']); - this.basicScanCodeTextMap.set(9, ['F']); - this.basicScanCodeTextMap.set(10, ['G']); - this.basicScanCodeTextMap.set(11, ['H']); - this.basicScanCodeTextMap.set(12, ['I']); - this.basicScanCodeTextMap.set(13, ['J']); - this.basicScanCodeTextMap.set(14, ['K']); - this.basicScanCodeTextMap.set(15, ['L']); - this.basicScanCodeTextMap.set(16, ['M']); - this.basicScanCodeTextMap.set(17, ['N']); - this.basicScanCodeTextMap.set(18, ['O']); - this.basicScanCodeTextMap.set(19, ['P']); - this.basicScanCodeTextMap.set(20, ['Q']); - this.basicScanCodeTextMap.set(21, ['R']); - this.basicScanCodeTextMap.set(22, ['S']); - this.basicScanCodeTextMap.set(23, ['T']); - this.basicScanCodeTextMap.set(24, ['U']); - this.basicScanCodeTextMap.set(25, ['V']); - this.basicScanCodeTextMap.set(26, ['W']); - this.basicScanCodeTextMap.set(27, ['X']); - this.basicScanCodeTextMap.set(28, ['Y']); - this.basicScanCodeTextMap.set(29, ['Z']); - this.basicScanCodeTextMap.set(30, ['1', '!']); - this.basicScanCodeTextMap.set(31, ['2', '@']); - this.basicScanCodeTextMap.set(32, ['3', '#']); - this.basicScanCodeTextMap.set(33, ['4', '$']); - this.basicScanCodeTextMap.set(34, ['5', '%']); - this.basicScanCodeTextMap.set(35, ['6', '^']); - this.basicScanCodeTextMap.set(36, ['7', '&']); - this.basicScanCodeTextMap.set(37, ['8', '*']); - this.basicScanCodeTextMap.set(38, ['9', '(']); - this.basicScanCodeTextMap.set(39, ['0', ')']); + + for (const scancodeText of Object.keys(US_BASIC_SCANCODE_TEXTS)) { + const scancode = Number(scancodeText); + const texts = getBasicScancodeTexts(scancode, this.keyLanguage); + if (texts) { + this.basicScanCodeTextMap.set(scancode, [...texts]); + } + } + this.basicScanCodeTextMap.set(40, [this.getOsSpecificText(OsSpecificKeys.Enter)]); - this.basicScanCodeTextMap.set(41, ['Esc']); - this.basicScanCodeTextMap.set(42, ['Backspace']); - this.basicScanCodeTextMap.set(43, ['Tab']); - this.basicScanCodeTextMap.set(44, ['Space']); - this.basicScanCodeTextMap.set(45, ['-', '_']); - this.basicScanCodeTextMap.set(46, ['=', '+']); - this.basicScanCodeTextMap.set(47, ['[', '{']); - this.basicScanCodeTextMap.set(48, [']', '}']); - this.basicScanCodeTextMap.set(49, ['\\', '|']); - this.basicScanCodeTextMap.set(50, ['ISO key', '#']); - this.basicScanCodeTextMap.set(51, [';', ':']); - this.basicScanCodeTextMap.set(52, ['\'', '"']); - this.basicScanCodeTextMap.set(53, ['`', '~']); - this.basicScanCodeTextMap.set(54, [',', '<']); - this.basicScanCodeTextMap.set(55, ['.', '>']); - this.basicScanCodeTextMap.set(56, ['/', '?']); - this.basicScanCodeTextMap.set(57, ['Caps Lock']); - this.basicScanCodeTextMap.set(58, ['F1']); - this.basicScanCodeTextMap.set(59, ['F2']); - this.basicScanCodeTextMap.set(60, ['F3']); - this.basicScanCodeTextMap.set(61, ['F4']); - this.basicScanCodeTextMap.set(62, ['F5']); - this.basicScanCodeTextMap.set(63, ['F6']); - this.basicScanCodeTextMap.set(64, ['F7']); - this.basicScanCodeTextMap.set(65, ['F8']); - this.basicScanCodeTextMap.set(66, ['F9']); - this.basicScanCodeTextMap.set(67, ['F10']); - this.basicScanCodeTextMap.set(68, ['F11']); - this.basicScanCodeTextMap.set(69, ['F12']); - this.basicScanCodeTextMap.set(70, ['PrtScn', 'SysRq']); - this.basicScanCodeTextMap.set(71, ['ScrLk']); - this.basicScanCodeTextMap.set(72, ['Pause']); - this.basicScanCodeTextMap.set(73, ['Insert']); - this.basicScanCodeTextMap.set(74, ['Home']); - this.basicScanCodeTextMap.set(75, ['PgUp']); - this.basicScanCodeTextMap.set(76, ['Del']); - this.basicScanCodeTextMap.set(77, ['End']); - this.basicScanCodeTextMap.set(78, ['PgDn']); - this.basicScanCodeTextMap.set(79, ['Right Arrow']); - this.basicScanCodeTextMap.set(80, ['Left Arrow']); - this.basicScanCodeTextMap.set(81, ['Down Arrow']); - this.basicScanCodeTextMap.set(82, ['Up Arrow']); - this.basicScanCodeTextMap.set(83, ['NumLk']); - this.basicScanCodeTextMap.set(84, ['Np /']); - this.basicScanCodeTextMap.set(85, ['Np *']); - this.basicScanCodeTextMap.set(86, ['Np -']); - this.basicScanCodeTextMap.set(87, ['Np +']); this.basicScanCodeTextMap.set(88, [`Np ${this.getOsSpecificText(OsSpecificKeys.Enter)}`]); - this.basicScanCodeTextMap.set(89, ['Np 1', 'End']); - this.basicScanCodeTextMap.set(90, ['Np 2', 'icon-kbd__mod--arrow-down']); - this.basicScanCodeTextMap.set(91, ['Np 3', 'PgDn']); - this.basicScanCodeTextMap.set(92, ['Np 4', 'icon-kbd__mod--arrow-left']); - this.basicScanCodeTextMap.set(93, ['Np 5']); - this.basicScanCodeTextMap.set(94, ['Np 6', 'icon-kbd__mod--arrow-right']); - this.basicScanCodeTextMap.set(95, ['Np 7', 'Home']); - this.basicScanCodeTextMap.set(96, ['Np 8', 'icon-kbd__mod--arrow-up']); - this.basicScanCodeTextMap.set(97, ['Np 9', 'PgUp']); - this.basicScanCodeTextMap.set(98, ['Np 0', 'Insert']); - this.basicScanCodeTextMap.set(99, ['Np .', 'Del']); - this.basicScanCodeTextMap.set(100, ['ISO key', '|']); - this.basicScanCodeTextMap.set(101, ['Menu']); - this.basicScanCodeTextMap.set(104, ['F13']); - this.basicScanCodeTextMap.set(105, ['F14']); - this.basicScanCodeTextMap.set(106, ['F15']); - this.basicScanCodeTextMap.set(107, ['F16']); - this.basicScanCodeTextMap.set(108, ['F17']); - this.basicScanCodeTextMap.set(109, ['F18']); - this.basicScanCodeTextMap.set(110, ['F19']); - this.basicScanCodeTextMap.set(111, ['F20']); - this.basicScanCodeTextMap.set(112, ['F21']); - this.basicScanCodeTextMap.set(113, ['F22']); - this.basicScanCodeTextMap.set(114, ['F23']); - this.basicScanCodeTextMap.set(115, ['F24']); - this.basicScanCodeTextMap.set(135, ['Int1']); - this.basicScanCodeTextMap.set(136, ['Int2']); - this.basicScanCodeTextMap.set(137, ['Int3']); - this.basicScanCodeTextMap.set(138, ['Int4']); - this.basicScanCodeTextMap.set(139, ['Int5']); - this.basicScanCodeTextMap.set(144, ['Lang1']); - this.basicScanCodeTextMap.set(145, ['Lang2']); - this.basicScanCodeTextMap.set(176, ['00']); - this.basicScanCodeTextMap.set(177, ['000']); this.mediaScanCodeTextMap = new Map(); this.mediaScanCodeTextMap.set(176, ['Play']); diff --git a/packages/uhk-web/src/app/store/actions/app.ts b/packages/uhk-web/src/app/store/actions/app.ts index 3edf75caa69..db70132318c 100644 --- a/packages/uhk-web/src/app/store/actions/app.ts +++ b/packages/uhk-web/src/app/store/actions/app.ts @@ -1,6 +1,6 @@ import { Action } from '@ngrx/store'; -import { ApplicationSettings, AppStartInfo, AppTheme, HardwareConfiguration, MacroGroupingSettings, Notification } from 'uhk-common'; +import { ApplicationSettings, AppStartInfo, AppTheme, HardwareConfiguration, KeyLanguage, MacroGroupingSettings, Notification } from 'uhk-common'; import { ElectronLogEntry } from '../../models/xterm-log'; import { NavigationPayload } from '../../models'; @@ -32,6 +32,7 @@ export enum ActionTypes { SetMacroGroupingSettings = '[app] Set macro grouping settings', ToggleMinimizeToTray = '[app] Toggle minimize to tray', SetAppTheme = '[app] Set application theme', + SetKeyLanguage = '[app] Set key language', LoadAppStartInfo = '[app] Load app start info', StartKeypressCapturing = '[app] Start keypress capturing', StopKeypressCapturing = '[app] Stop keypress capturing', @@ -196,6 +197,13 @@ export class SetAppThemeAction implements Action { } } +export class SetKeyLanguageAction implements Action { + type = ActionTypes.SetKeyLanguage; + + constructor(public payload: KeyLanguage) { + } +} + export class LoadAppStartInfoAction implements Action { type = ActionTypes.LoadAppStartInfo; } @@ -254,6 +262,7 @@ export type Actions | SetMacroGroupingSettingsAction | ToggleMinimizeToTrayAction | SetAppThemeAction + | SetKeyLanguageAction | LoadAppStartInfoAction | StartKeypressCapturingAction | StopKeypressCapturingAction diff --git a/packages/uhk-web/src/app/store/effects/app.ts b/packages/uhk-web/src/app/store/effects/app.ts index 869d2f27884..0fc6103dab9 100644 --- a/packages/uhk-web/src/app/store/effects/app.ts +++ b/packages/uhk-web/src/app/store/effects/app.ts @@ -154,6 +154,7 @@ export class ApplicationEffects { ofType( ActionTypes.ErrorPanelSizeChanged, ActionTypes.SetAppTheme, + ActionTypes.SetKeyLanguage, ActionTypes.SetMacroGroupingSettings, ActionTypes.ToggleAnimationEnabled, ActionTypes.ToggleKeyboardHalvesAlwaysJoined, diff --git a/packages/uhk-web/src/app/store/index.ts b/packages/uhk-web/src/app/store/index.ts index 8ba02d3730c..6dbed682bf5 100644 --- a/packages/uhk-web/src/app/store/index.ts +++ b/packages/uhk-web/src/app/store/index.ts @@ -208,6 +208,7 @@ export const getAnimationEnabled = createSelector(appState, fromApp.getAnimation export const getMacroGroupingSettings = createSelector(appState, fromApp.getMacroGroupingSettings); export const getMinimizeToTray = createSelector(appState, fromApp.getMinimizeToTray); export const getAppTheme = createSelector(appState, fromApp.getAppTheme); +export const getKeyLanguage = createSelector(appState, fromApp.getKeyLanguage); export const getUhkThemeColors = createSelector(getAppTheme, (theme): UhkThemeColors => { return defaultUhkThemeColors(theme); }); @@ -906,6 +907,7 @@ export const getApplicationSettings = createSelector( everAttemptedSavingToKeyboard: app.everAttemptedSavingToKeyboard, animationEnabled: app.animationEnabled, appTheme: app.appTheme, + keyLanguage: app.keyLanguage, backlightingColorPalette, keyboardHalvesAlwaysJoined, minimizeToTray: app.minimizeToTray, diff --git a/packages/uhk-web/src/app/store/reducers/app.reducer.ts b/packages/uhk-web/src/app/store/reducers/app.reducer.ts index 50954294259..223a2a18e5f 100644 --- a/packages/uhk-web/src/app/store/reducers/app.reducer.ts +++ b/packages/uhk-web/src/app/store/reducers/app.reducer.ts @@ -2,10 +2,12 @@ import { ROUTER_NAVIGATION, RouterNavigationAction } from '@ngrx/router-store'; import { AppTheme, CommandLineArgs, + DEFAULT_KEY_LANGUAGE, DEFAULT_MACRO_GROUPING_SETTINGS, disableAgentUpgradeProtection, HardwareConfiguration, KeyboardLayout, + KeyLanguage, MacroGroupingSettings, Notification, NotificationType, @@ -23,6 +25,7 @@ const DEFAULT_ERROR_PANEL_HEIGHT = 10; export interface State { appTheme: AppTheme; + keyLanguage: KeyLanguage; animationEnabled: boolean; minimizeToTray: boolean; errorPanelHeight: number; @@ -48,6 +51,7 @@ export interface State { export const initialState: State = { appTheme: AppTheme.System, + keyLanguage: DEFAULT_KEY_LANGUAGE, animationEnabled: true, minimizeToTray: false, errorPanelHeight: DEFAULT_ERROR_PANEL_HEIGHT, @@ -216,6 +220,7 @@ export function reducer( everAttemptedSavingToKeyboard: settings.everAttemptedSavingToKeyboard, animationEnabled: settings.animationEnabled, appTheme: settings.appTheme || AppTheme.System, + keyLanguage: settings.keyLanguage || DEFAULT_KEY_LANGUAGE, macroGrouping: normalizeMacroGroupingSettings(settings.macroGrouping), minimizeToTray: settings.minimizeToTray ?? false, }; @@ -254,6 +259,12 @@ export function reducer( appTheme: (action as App.SetAppThemeAction).payload }; + case App.ActionTypes.SetKeyLanguage: + return { + ...state, + keyLanguage: (action as App.SetKeyLanguageAction).payload + }; + default: return state; } @@ -294,6 +305,7 @@ export const getAnimationEnabled = (state: State): boolean => state.animationEna export const getMacroGroupingSettings = (state: State): MacroGroupingSettings => state.macroGrouping; export const getMinimizeToTray = (state: State): boolean => state.minimizeToTray; export const getAppTheme = (state: State): AppTheme => state.appTheme; +export const getKeyLanguage = (state: State): KeyLanguage => state.keyLanguage; export const getHardwareConfiguration = (state: State): HardwareConfiguration => state.hardwareConfig; export const getPlatform = (state: State): string => state.platform; export const isColorPickerEyeDropperEnabled = (state: State): boolean => !state.isRunningOnWayland;