From 91de325268aa4e82e454584d19268fbba1a3314e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Wed, 15 Jul 2026 13:42:50 +0200 Subject: [PATCH 1/5] Show macro key assignments on the macro page. List where each macro is used with links back to the assigned key, and avoid treating Jump to macro navigation as a config change. Co-authored-by: Cursor --- .../util/find-macro-key-assignments.test.ts | 69 +++++++++++ .../src/util/find-macro-key-assignments.ts | 37 ++++++ packages/uhk-common/src/util/index.ts | 1 + .../components/back-to/back-to.component.ts | 4 +- .../keymap/add/keymap-add.component.ts | 3 +- .../keymap/edit/keymap-edit.component.ts | 3 +- .../macro/edit/macro-edit.component.ts | 13 +- .../macro/header/macro-header.component.html | 9 +- .../macro/header/macro-header.component.scss | 23 ++++ .../macro/header/macro-header.component.ts | 113 +++++++++++++++++- packages/uhk-web/src/app/models/index.ts | 1 + .../models/macro-key-assignment-view-model.ts | 9 ++ .../navigate-to-module-settings-payload.ts | 1 + .../uhk-web/src/app/store/effects/macro.ts | 15 +-- .../src/app/store/effects/user-config.ts | 11 ++ .../app/store/reducers/user-configuration.ts | 6 +- .../src/app/util/get-default-key-label.ts | 61 ++++++++++ 17 files changed, 344 insertions(+), 35 deletions(-) create mode 100644 packages/uhk-common/src/util/find-macro-key-assignments.test.ts create mode 100644 packages/uhk-common/src/util/find-macro-key-assignments.ts create mode 100644 packages/uhk-web/src/app/models/macro-key-assignment-view-model.ts create mode 100644 packages/uhk-web/src/app/util/get-default-key-label.ts diff --git a/packages/uhk-common/src/util/find-macro-key-assignments.test.ts b/packages/uhk-common/src/util/find-macro-key-assignments.test.ts new file mode 100644 index 00000000000..5a00ce745c5 --- /dev/null +++ b/packages/uhk-common/src/util/find-macro-key-assignments.test.ts @@ -0,0 +1,69 @@ +import { describe, it } from 'node:test'; + +import { Keymap, Layer, LayerName, Module, PlayMacroAction } from '../config-serializer/index.js'; +import { findMacroKeyAssignments } from './find-macro-key-assignments.js'; + +function createKeymapWithMacroAssignment( + abbreviation: string, + name: string, + layerId: LayerName, + moduleId: number, + keyId: number, + macroId: number +): Keymap { + const keyActions = []; + keyActions[keyId] = Object.assign(new PlayMacroAction(), { macroId }); + + const keymap = new Keymap(); + keymap.abbreviation = abbreviation; + keymap.name = name; + keymap.layers = [ + Object.assign(new Layer(), { + id: layerId, + modules: [ + Object.assign(new Module(), { + id: moduleId, + keyActions, + }), + ], + }), + ]; + + return keymap; +} + +describe('findMacroKeyAssignments', () => { + it('returns assignments for the requested macro', ({ assert }) => { + const keymaps = [ + createKeymapWithMacroAssignment('QWR', 'QWERTY for PC', LayerName.fn, 0, 2, 5), + createKeymapWithMacroAssignment('DVO', 'Dvorak', LayerName.base, 1, 0, 5), + createKeymapWithMacroAssignment('QWR', 'QWERTY for PC', LayerName.mod, 0, 1, 9), + ]; + + const assignments = findMacroKeyAssignments(keymaps, 5); + + assert.equal(assignments.length, 2); + assert.deepEqual(assignments[0], { + keymapAbbreviation: 'QWR', + keymapName: 'QWERTY for PC', + layerId: LayerName.fn, + moduleId: 0, + keyId: 2, + }); + assert.deepEqual(assignments[1], { + keymapAbbreviation: 'DVO', + keymapName: 'Dvorak', + layerId: LayerName.base, + moduleId: 1, + keyId: 0, + }); + }); + + it('returns an empty list when the macro is not assigned', ({ assert }) => { + const keymaps = [ + createKeymapWithMacroAssignment('QWR', 'QWERTY for PC', LayerName.fn, 0, 2, 5), + ]; + + assert.deepEqual(findMacroKeyAssignments(keymaps, 99), []); + }); +}); diff --git a/packages/uhk-common/src/util/find-macro-key-assignments.ts b/packages/uhk-common/src/util/find-macro-key-assignments.ts new file mode 100644 index 00000000000..dc64a368145 --- /dev/null +++ b/packages/uhk-common/src/util/find-macro-key-assignments.ts @@ -0,0 +1,37 @@ +import { Keymap, LayerName, PlayMacroAction } from '../config-serializer/index.js'; + +export interface MacroKeyAssignment { + keymapAbbreviation: string; + keymapName: string; + layerId: LayerName; + moduleId: number; + keyId: number; +} + +export function findMacroKeyAssignments(keymaps: Keymap[], macroId: number): MacroKeyAssignment[] { + const assignments: MacroKeyAssignment[] = []; + + for (const keymap of keymaps) { + for (const layer of keymap.layers) { + for (const module of layer.modules) { + for (let keyId = 0; keyId < module.keyActions.length; keyId++) { + const keyAction = module.keyActions[keyId]; + + if (!(keyAction instanceof PlayMacroAction) || keyAction.macroId !== macroId) { + continue; + } + + assignments.push({ + keymapAbbreviation: keymap.abbreviation, + keymapName: keymap.name, + layerId: layer.id, + moduleId: module.id, + keyId, + }); + } + } + } + } + + return assignments; +} diff --git a/packages/uhk-common/src/util/index.ts b/packages/uhk-common/src/util/index.ts index 9917c00d7e2..0a899808084 100644 --- a/packages/uhk-common/src/util/index.ts +++ b/packages/uhk-common/src/util/index.ts @@ -6,6 +6,7 @@ export * from './convert-array-to-hex-string.js'; export * from './create-md5-hash.js'; export * from './date-formatter.js'; export * from './disable-agent-upgrade-protection.js'; +export * from './find-macro-key-assignments.js'; export * from './find-uhk-module-by-id.js'; export * from './get-formatted-timestamp.js'; export * from './get-md5-hash-from-file-name.js'; diff --git a/packages/uhk-web/src/app/components/back-to/back-to.component.ts b/packages/uhk-web/src/app/components/back-to/back-to.component.ts index eec3d5fae9b..6101927d639 100644 --- a/packages/uhk-web/src/app/components/back-to/back-to.component.ts +++ b/packages/uhk-web/src/app/components/back-to/back-to.component.ts @@ -7,13 +7,14 @@ import { Subscription } from 'rxjs'; standalone: false, template: `
- Back to {{backText}} + Back to {{ backText }}{{ backSuffix }}
`, }) export class BackToComponent implements OnDestroy { backUrl: string | undefined; backText: string; + backSuffix = ''; queryParams = {}; private routeSubscription: Subscription; @@ -25,6 +26,7 @@ export class BackToComponent implements OnDestroy { const backUrl = new URL(params.backUrl as string, window.location.origin); this.backUrl = backUrl.pathname; this.backText = params.backText; + this.backSuffix = params.backSuffix ?? ''; this.queryParams = {}; for (const key of backUrl.searchParams.keys()) { this.queryParams[key] = backUrl.searchParams.get(key); diff --git a/packages/uhk-web/src/app/components/keymap/add/keymap-add.component.ts b/packages/uhk-web/src/app/components/keymap/add/keymap-add.component.ts index 4f1df6cfe61..19644bde444 100644 --- a/packages/uhk-web/src/app/components/keymap/add/keymap-add.component.ts +++ b/packages/uhk-web/src/app/components/keymap/add/keymap-add.component.ts @@ -68,7 +68,8 @@ export class KeymapAddComponent implements OnDestroy, OnInit { navigateToModuleSettings(moduleId: number): void { this.store.dispatch(new NavigateToModuleSettings({ backUrl: `/add-keymap/${encodeURIComponent(this.keymap.abbreviation)}`, - backText: `new "${this.keymap.name}" keymap`, + backText: `new ${this.keymap.name}`, + backSuffix: ' keymap', moduleId })); } diff --git a/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.ts b/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.ts index 149f4a35b32..fdc17065126 100644 --- a/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.ts +++ b/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.ts @@ -161,7 +161,8 @@ export class KeymapEditComponent implements OnDestroy { navigateToModuleSettings(moduleId: number): void { this.store.dispatch(new NavigateToModuleSettings({ backUrl: `/keymap/${encodeURIComponent(this.keymap.abbreviation)}?layer=${this.selectedLayer}`, - backText: `"${this.keymap.name}" keymap`, + backText: this.keymap.name, + backSuffix: ' keymap', moduleId, })); } 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 46ca87d0936..ca75224b4a2 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 @@ -59,9 +59,6 @@ export class MacroEditComponent implements OnDestroy { left: 100, right: 0 }; - - private backUrl: string; - private backUrlText: string; private subscriptions = new Subscription(); constructor(private store: Store, @@ -90,9 +87,6 @@ export class MacroEditComponent implements OnDestroy { this.smartMacroDocUrl$ = store.select(selectSmartMacroDocUrl); this.smartMacroPanelVisibility$ = store.select(getSmartMacroPanelVisibility); this.subscriptions.add(this.route.queryParams.subscribe(params => { - this.backUrl = params.backUrl; - this.backUrlText = params.backText; - if (params.actionIndex) { if (params.actionIndex === 'new') { this.selectedMacroActionIdModel = { @@ -150,8 +144,6 @@ export class MacroEditComponent implements OnDestroy { this.router.navigate([], { queryParams: { actionIndex: model?.id, - backText: this.backUrlText, - backUrl: this.backUrl, inlineEdit: model?.inlineEdit } }); @@ -173,10 +165,7 @@ export class MacroEditComponent implements OnDestroy { private hideActiveEditor(): void { if (!this.selectedMacroActionIdModel?.inlineEdit) { this.router.navigate([], { - queryParams: { - backText: this.backUrlText, - backUrl: this.backUrl, - }, + queryParams: {}, }); } } diff --git a/packages/uhk-web/src/app/components/macro/header/macro-header.component.html b/packages/uhk-web/src/app/components/macro/header/macro-header.component.html index e1b83d9a8a8..73dd2360b5d 100644 --- a/packages/uhk-web/src/app/components/macro/header/macro-header.component.html +++ b/packages/uhk-web/src/app/components/macro/header/macro-header.component.html @@ -18,4 +18,11 @@ placement="bottom-right" (click)="duplicateMacro()"> - +
+ Used on: + + {{ assignment.label }}, + +
diff --git a/packages/uhk-web/src/app/components/macro/header/macro-header.component.scss b/packages/uhk-web/src/app/components/macro/header/macro-header.component.scss index 2d3de0790f3..99389b963fc 100644 --- a/packages/uhk-web/src/app/components/macro/header/macro-header.component.scss +++ b/packages/uhk-web/src/app/components/macro/header/macro-header.component.scss @@ -1,5 +1,28 @@ +:host { + display: block; +} .macro { + &__assignments { + font-size: 0.875rem; + line-height: 1.5; + padding-top: 1.25em; + } + + &__assignments-label { + margin-right: 0.35em; + white-space: nowrap; + } + + &__assignment-link { + text-decoration: underline; + white-space: nowrap; + } + + &__assignment-separator { + white-space: pre; + } + &__remove { font-size: 0.75em; top: 7px; diff --git a/packages/uhk-web/src/app/components/macro/header/macro-header.component.ts b/packages/uhk-web/src/app/components/macro/header/macro-header.component.ts index def5f861f78..0314c1c9882 100644 --- a/packages/uhk-web/src/app/components/macro/header/macro-header.component.ts +++ b/packages/uhk-web/src/app/components/macro/header/macro-header.component.ts @@ -1,19 +1,37 @@ import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, Input, OnChanges, + OnDestroy, SimpleChanges, ViewChild } from '@angular/core'; import { Store } from '@ngrx/store'; import { faCopy, faPlay, faTrash } from '@fortawesome/free-solid-svg-icons'; -import { Macro, MAX_ALLOWED_MACROS_TOOLTIP } from 'uhk-common'; +import { + findMacroKeyAssignments, + Keymap, + LayerName, + Macro, + MacroKeyAssignment, + MAX_ALLOWED_MACROS_TOOLTIP, + UserConfiguration +} from 'uhk-common'; +import { combineLatest, Subscription } from 'rxjs'; import { DuplicateMacroAction, EditMacroNameAction, RemoveMacroAction } from '../../../store/actions/macro'; -import { AppState } from '../../../store'; +import { AppState, getDefaultUserConfiguration, getKeymaps } from '../../../store'; +import { MacroKeyAssignmentViewModel } from '../../../models'; import * as util from '../../../util'; +import { getDefaultQwertyKeyLabel } from '../../../util/get-default-key-label'; import { AutoGrowInputComponent } from '../../auto-grow-input'; +import { initLayerOptions } from '../../../store/reducers/layer-options'; +import { MapperService } from '../../../services/mapper.service'; + +const MACRO_KEY_ASSIGNMENT_SEPARATOR = ' ⭢ '; +const LAYER_OPTIONS = initLayerOptions(); @Component({ selector: 'macro-header', @@ -22,27 +40,58 @@ import { AutoGrowInputComponent } from '../../auto-grow-input'; styleUrls: ['./macro-header.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class MacroHeaderComponent implements OnChanges { +export class MacroHeaderComponent implements OnChanges, OnDestroy { @Input() macro: Macro; @Input() isNew: boolean; @Input() maxMacroCountReached: boolean; @ViewChild(AutoGrowInputComponent, { static: true }) macroName: AutoGrowInputComponent; + assignments: MacroKeyAssignmentViewModel[] = []; + faCopy = faCopy; faPlay = faPlay; faTrash = faTrash; maxAllowedMacrosTooltip = MAX_ALLOWED_MACROS_TOOLTIP; - constructor(private store: Store) { + private keymaps: Keymap[] = []; + private defaultUserConfiguration: UserConfiguration; + private subscriptions = new Subscription(); + + constructor(private store: Store, + private mapper: MapperService, + private cdRef: ChangeDetectorRef) { + this.subscriptions.add( + combineLatest([ + this.store.select(getKeymaps), + this.store.select(getDefaultUserConfiguration), + ]).subscribe(([keymaps, defaultUserConfiguration]) => { + this.keymaps = keymaps; + this.defaultUserConfiguration = defaultUserConfiguration; + this.updateAssignments(); + }) + ); } ngOnChanges(changes: SimpleChanges): void { if (changes.macro) { this.macroName.writeValue(changes.macro.currentValue.name as string); + this.updateAssignments(); } } + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + getKeymapQueryParams(assignment: MacroKeyAssignmentViewModel) { + return { + layer: assignment.layerId, + module: assignment.moduleId, + key: assignment.keyId, + }; + } + removeMacro() { this.store.dispatch(new RemoveMacroAction(this.macro.id)); } @@ -61,4 +110,60 @@ export class MacroHeaderComponent implements OnChanges { this.store.dispatch(new EditMacroNameAction({ id: this.macro.id, name })); } + + private updateAssignments(): void { + if (!this.macro || !this.keymaps || !this.defaultUserConfiguration) { + this.assignments = []; + this.cdRef.markForCheck(); + return; + } + + const layerOptions = LAYER_OPTIONS; + this.assignments = findMacroKeyAssignments(this.keymaps, this.macro.id) + .sort((first, second) => this.compareAssignments(first, second, layerOptions)) + .map(assignment => { + const layerName = layerOptions.get(assignment.layerId)?.name ?? LayerName[assignment.layerId]; + const keyLabel = getDefaultQwertyKeyLabel( + this.defaultUserConfiguration, + assignment.moduleId, + assignment.keyId, + this.mapper + ); + + return { + keymapAbbreviation: assignment.keymapAbbreviation, + layerId: assignment.layerId, + moduleId: assignment.moduleId, + keyId: assignment.keyId, + label: `${assignment.keymapName}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${layerName}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${keyLabel}`, + }; + }); + + this.cdRef.markForCheck(); + } + + private compareAssignments( + first: MacroKeyAssignment, + second: MacroKeyAssignment, + layerOptions: ReturnType + ): number { + const keymapComparison = first.keymapName.localeCompare(second.keymapName); + + if (keymapComparison !== 0) { + return keymapComparison; + } + + const firstLayerOrder = layerOptions.get(first.layerId)?.order ?? 0; + const secondLayerOrder = layerOptions.get(second.layerId)?.order ?? 0; + + if (firstLayerOrder !== secondLayerOrder) { + return firstLayerOrder - secondLayerOrder; + } + + if (first.moduleId !== second.moduleId) { + return first.moduleId - second.moduleId; + } + + return first.keyId - second.keyId; + } } diff --git a/packages/uhk-web/src/app/models/index.ts b/packages/uhk-web/src/app/models/index.ts index 4369d1ae62a..9dbdb3dfe33 100644 --- a/packages/uhk-web/src/app/models/index.ts +++ b/packages/uhk-web/src/app/models/index.ts @@ -14,6 +14,7 @@ export * from './firmware-upgrade-steps'; export * from './last-edited-key'; export * from './layer-option'; export * from './load-user-configuration-from-file-payload'; +export * from './macro-key-assignment-view-model'; export * from './macro-menu-item'; export * from './modify-color-of-backlighting-color-palette-payload'; export * from './navigation-payload'; diff --git a/packages/uhk-web/src/app/models/macro-key-assignment-view-model.ts b/packages/uhk-web/src/app/models/macro-key-assignment-view-model.ts new file mode 100644 index 00000000000..74ebc8c7bec --- /dev/null +++ b/packages/uhk-web/src/app/models/macro-key-assignment-view-model.ts @@ -0,0 +1,9 @@ +import { LayerName } from 'uhk-common'; + +export interface MacroKeyAssignmentViewModel { + keymapAbbreviation: string; + layerId: LayerName; + moduleId: number; + keyId: number; + label: string; +} diff --git a/packages/uhk-web/src/app/models/navigate-to-module-settings-payload.ts b/packages/uhk-web/src/app/models/navigate-to-module-settings-payload.ts index 41a6a680ec5..9c3cd32629c 100644 --- a/packages/uhk-web/src/app/models/navigate-to-module-settings-payload.ts +++ b/packages/uhk-web/src/app/models/navigate-to-module-settings-payload.ts @@ -1,4 +1,5 @@ export interface NavigateToModuleSettingsPayload { + backSuffix?: string; backText: string; backUrl: string; moduleId: number; diff --git a/packages/uhk-web/src/app/store/effects/macro.ts b/packages/uhk-web/src/app/store/effects/macro.ts index e6ca97c1d65..88529a1a298 100644 --- a/packages/uhk-web/src/app/store/effects/macro.ts +++ b/packages/uhk-web/src/app/store/effects/macro.ts @@ -55,25 +55,16 @@ export class MacroEffects { withLatestFrom(this.store.select(getSelectedMacro)), tap(([action, newMacro]) => { if (action.payload.keyAction.assignNewMacro || action.payload.keyAction.navigateToMacro) { - this.navigateToNewMacro(newMacro, action); + this.navigateToNewMacro(newMacro); } }), ), { dispatch: false } ); - private navigateToNewMacro(newMacro: Macro, action?: Keymaps.SaveKeyAction): Promise { + private navigateToNewMacro(newMacro: Macro): Promise { if (newMacro) { - let queryParams = {}; - if (action) { - const payload = action.payload; - queryParams = { - backUrl: `/keymap/${encodeURIComponent(payload.keymap.abbreviation)}?layer=${payload.layer}&module=${payload.module}&key=${payload.key}`, - backText: `"${payload.keymap.name}" keymap`, - }; - } - - return this.router.navigate(['/macro', newMacro.id], { queryParams }); + return this.router.navigate(['/macro', newMacro.id]); } return this.router.navigate(['/macro']); diff --git a/packages/uhk-web/src/app/store/effects/user-config.ts b/packages/uhk-web/src/app/store/effects/user-config.ts index 8705ad69dbd..94c29343fcb 100644 --- a/packages/uhk-web/src/app/store/effects/user-config.ts +++ b/packages/uhk-web/src/app/store/effects/user-config.ts @@ -121,6 +121,7 @@ export class UserConfigEffects { ActionTypes.ReorderHostConnections, ActionTypes.RenameHostConnection, ActionTypes.SetHostConnectionSwitchover, ActionTypes.LoadTypingBehaviorPreset, ), + filter(action => !isNavigateToMacroSaveKey(action)), withLatestFrom(this.store.select(getUserConfiguration), this.store.select(getPrevUserConfiguration), this.store.select(getConnectedDevice)), mergeMap(([action, config, prevUserConfiguration, uhkDeviceProduct]) => { config = Object.assign(new UserConfiguration(), config); @@ -376,6 +377,7 @@ export class UserConfigEffects { [module.configPath], { queryParams: { + backSuffix: action.payload.backSuffix, backText: action.payload.backText, backUrl: action.payload.backUrl, }, @@ -466,6 +468,15 @@ export class UserConfigEffects { } } +function isNavigateToMacroSaveKey(action: { type: string }): boolean { + if (action.type !== Keymaps.ActionTypes.SaveKey) { + return false; + } + + const keyAction = (action as Keymaps.SaveKeyAction).payload.keyAction; + return keyAction.navigateToMacro && !keyAction.assignNewMacro; +} + function updateUserConfigurationWithLastSaveInfo(userConfiguration: UserConfiguration, rightModuleInfo: RightModuleInfo) { const newUserConfiguration = userConfiguration.clone() newUserConfiguration.lastSaveAgentTag = `${VERSIONS.agentRepo}/${VERSIONS.agentTag}`; diff --git a/packages/uhk-web/src/app/store/reducers/user-configuration.ts b/packages/uhk-web/src/app/store/reducers/user-configuration.ts index f946c23f3e8..c5245003472 100644 --- a/packages/uhk-web/src/app/store/reducers/user-configuration.ts +++ b/packages/uhk-web/src/app/store/reducers/user-configuration.ts @@ -856,10 +856,10 @@ export function reducer( }; } else if (originalPlayMacroAction && payload.keyAction.navigateToMacro) { - newState = { - ...newState, + return { + ...state, selectedMacroId: originalPlayMacroAction.macroId - } + }; } return { diff --git a/packages/uhk-web/src/app/util/get-default-key-label.ts b/packages/uhk-web/src/app/util/get-default-key-label.ts new file mode 100644 index 00000000000..48c1ca84529 --- /dev/null +++ b/packages/uhk-web/src/app/util/get-default-key-label.ts @@ -0,0 +1,61 @@ +import { KeystrokeAction, LayerName, UserConfiguration } from 'uhk-common'; + +import { MapperService } from '../services/mapper.service'; + +export const DEFAULT_QWERTY_KEYMAP_ABBREVIATION = 'QWR'; + +export function getDefaultQwertyKeyLabel( + defaultUserConfiguration: UserConfiguration, + moduleId: number, + keyId: number, + mapper: MapperService +): string { + const qwertyKeymap = defaultUserConfiguration.keymaps + .find(keymap => keymap.abbreviation === DEFAULT_QWERTY_KEYMAP_ABBREVIATION); + + if (!qwertyKeymap) { + return fallbackKeyLabel(keyId); + } + + const baseLayer = qwertyKeymap.layers.find(layer => layer.id === LayerName.base); + + if (!baseLayer) { + return fallbackKeyLabel(keyId); + } + + const module = baseLayer.modules.find(moduleConfig => moduleConfig.id === moduleId); + + if (!module) { + return fallbackKeyLabel(keyId); + } + + const keyAction = module.keyActions[keyId]; + + if (!(keyAction instanceof KeystrokeAction)) { + return fallbackKeyLabel(keyId); + } + + return formatKeystrokeLabel(keyAction, mapper); +} + +function formatKeystrokeLabel(keystrokeAction: KeystrokeAction, mapper: MapperService): string { + if (!keystrokeAction.hasScancode()) { + return fallbackKeyLabel(); + } + + const labelParts = mapper.scanCodeToText(keystrokeAction.scancode, keystrokeAction.type); + + if (labelParts.length === 1) { + return labelParts[0]; + } + + if (labelParts[1]?.startsWith('icon')) { + return labelParts[0]; + } + + return labelParts.join(' '); +} + +function fallbackKeyLabel(keyId?: number): string { + return keyId === undefined ? 'Key' : `Key ${keyId}`; +} From 2f191a588712804d48df53a86445d7a1067ce619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Wed, 15 Jul 2026 21:05:28 +0200 Subject: [PATCH 2/5] Address review: keep UI helpers in uhk-web and slim the macro header. Move macro assignment finding out of uhk-common, compute Used on view models in the smart macro-edit container, prefer Bootstrap utilities over custom CSS, and add Cursor rules capturing the review guidance. Co-authored-by: Cursor --- .../rules/initialize-state-and-variables.mdc | 7 +- .cursor/rules/max-function-parameters.mdc | 18 +++ .cursor/rules/prefer-existing-styles.mdc | 29 +++++ .cursor/rules/smart-dumb-components.mdc | 34 ++++++ .cursor/rules/uhk-package-boundaries.mdc | 29 +++++ .../util/find-macro-key-assignments.test.ts | 69 ----------- packages/uhk-common/src/util/index.ts | 1 - .../components/back-to/back-to.component.ts | 2 +- .../macro/edit/macro-edit.component.html | 1 + .../macro/edit/macro-edit.component.ts | 53 +++++--- .../macro/header/macro-header.component.html | 8 +- .../macro/header/macro-header.component.scss | 24 ---- .../macro/header/macro-header.component.ts | 113 +----------------- .../build-macro-key-assignment-view-models.ts | 67 +++++++++++ .../util/find-macro-key-assignments.test.ts | 100 ++++++++++++++++ .../app}/util/find-macro-key-assignments.ts | 2 +- .../src/app/util/get-default-key-label.ts | 38 +++--- 17 files changed, 351 insertions(+), 244 deletions(-) create mode 100644 .cursor/rules/max-function-parameters.mdc create mode 100644 .cursor/rules/prefer-existing-styles.mdc create mode 100644 .cursor/rules/smart-dumb-components.mdc create mode 100644 .cursor/rules/uhk-package-boundaries.mdc delete mode 100644 packages/uhk-common/src/util/find-macro-key-assignments.test.ts create mode 100644 packages/uhk-web/src/app/util/build-macro-key-assignment-view-models.ts create mode 100644 packages/uhk-web/src/app/util/find-macro-key-assignments.test.ts rename packages/{uhk-common/src => uhk-web/src/app}/util/find-macro-key-assignments.ts (93%) diff --git a/.cursor/rules/initialize-state-and-variables.mdc b/.cursor/rules/initialize-state-and-variables.mdc index 22d8a682223..a4bc986720f 100644 --- a/.cursor/rules/initialize-state-and-variables.mdc +++ b/.cursor/rules/initialize-state-and-variables.mdc @@ -1,13 +1,14 @@ --- -description: Initialize variables and NgRx state with concrete defaults -globs: **/*.{ts,tsx} -alwaysApply: false +description: Initialize variables and NgRx state with concrete defaults; avoid optional chaining smell +alwaysApply: true --- # Initialize variables and NgRx state Initialize primitives and objects at declaration whenever a sensible default exists. The goal is typed, predictable values—not defensive `?.` and `??` scattered through selectors, templates, and components. +Treat widespread optional chaining (`?.`) and nullish coalescing (`??`) as a code smell: they often hide missing initialization or the wrong selector. Prefer concrete defaults and explicit control flow (`if` / early return) so optionality is intentional, not lazy. + ## General TypeScript - Give local variables an initial value when one is known at declaration time. diff --git a/.cursor/rules/max-function-parameters.mdc b/.cursor/rules/max-function-parameters.mdc new file mode 100644 index 00000000000..f76e42a0977 --- /dev/null +++ b/.cursor/rules/max-function-parameters.mdc @@ -0,0 +1,18 @@ +--- +description: Prefer options objects when a function needs more than three parameters +alwaysApply: true +--- + +# Function parameters + +Functions and methods with more than three parameters are hard to maintain. Prefer an options/payload object once the call needs four or more values. + +```typescript +// ❌ BAD +createKeymapWithMacroAssignment(abbr, name, layerId, moduleId, keyId, macroId) + +// ✅ GOOD +createKeymapWithMacroAssignment({ abbreviation, name, layerId, moduleId, keyId, macroId }) +``` + +Three-or-fewer scalar params are fine when the meaning stays obvious at the call site. diff --git a/.cursor/rules/prefer-existing-styles.mdc b/.cursor/rules/prefer-existing-styles.mdc new file mode 100644 index 00000000000..1b7d9d3809c --- /dev/null +++ b/.cursor/rules/prefer-existing-styles.mdc @@ -0,0 +1,29 @@ +--- +description: Prefer Bootstrap and existing styles over new custom CSS +alwaysApply: true +--- + +# Prefer existing styles + +Do not add custom CSS when Bootstrap utilities or existing shared styles already cover spacing, typography, and layout. + +## Prefer + +- Bootstrap utilities: `my-2`, `mb-3`, `me-1`, `small`, `text-nowrap`, `float-end`, … +- Patterns already used nearby (e.g. `back-to` uses `class="my-2"`) + +## Avoid + +- New BEM blocks for one-off spacing/font-size/weight that utilities already express +- Growing component SCSS with unexplained overrides that later confuse deletions + +```html + +
+ + + +
+``` + +Only add custom CSS when no existing utility/shared rule fits, and keep it minimal. diff --git a/.cursor/rules/smart-dumb-components.mdc b/.cursor/rules/smart-dumb-components.mdc new file mode 100644 index 00000000000..0c61b99fb5d --- /dev/null +++ b/.cursor/rules/smart-dumb-components.mdc @@ -0,0 +1,34 @@ +--- +description: Prefer smart containers / dumb leaf components with store-side calculations +alwaysApply: true +--- + +# Smart / dumb components + +Follow the smart/dumb (container/presentational) pattern already used in this codebase. + +## Responsibilities + +- **Store / selectors / smart parents**: derive view models, sort/filter/map domain data, combine streams. +- **Leaf components**: receive `@Input()`s, render them, emit `@Output()`s / dispatch obvious user intents. Avoid reading the store just to compute display data. + +```typescript +// ❌ BAD — deep leaf component selects and calculates +class MacroHeaderComponent { + constructor(store: Store) { + store.select(getKeymaps).subscribe(keymaps => this.assignments = map(keymaps)); + } +} + +// ✅ GOOD — smart parent selects; leaf renders +// macro-edit (smart) +assignments$ = this.store.select(getSelectedMacroKeyAssignments); + +// template + + +// macro-header (dumb regarding this data) +@Input() assignments: MacroKeyAssignmentViewModel[] = []; +``` + +Existing leaf components that already inject `Store` only to `dispatch` actions are acceptable when inherited; do not expand them with new selectors or calculation logic. diff --git a/.cursor/rules/uhk-package-boundaries.mdc b/.cursor/rules/uhk-package-boundaries.mdc new file mode 100644 index 00000000000..ce5007d0208 --- /dev/null +++ b/.cursor/rules/uhk-package-boundaries.mdc @@ -0,0 +1,29 @@ +--- +description: Keep UI and presentation code out of uhk-common +alwaysApply: true +--- + +# Package boundaries: uhk-common vs uhk-web + +`uhk-common` is for shared, UI-agnostic domain logic (config serializers, USB helpers, pure data transforms). Do **not** put presentation-oriented helpers there. + +## Belong in `uhk-web` (or uhk-agent), not `uhk-common` + +- Display labels, breadcrumbs, menu grouping, view-model mappers +- Anything that formats data for Angular templates +- Code that only Agent UI consumers need, even if it looks "pure" + +## Belong in `uhk-common` + +- Config/device models and serialization +- Algorithms that both Electron main and renderer need without UI assumptions + +```typescript +// ❌ BAD — lives in uhk-common only because tests were convenient there +export function findMacroKeyAssignments(...): MacroKeyAssignment[] { /* for macro page UI */ } + +// ✅ GOOD — presentation helper next to the UI that uses it +// packages/uhk-web/src/app/util/find-macro-key-assignments.ts +``` + +If you want unit tests for UI helpers and `uhk-web` has no runner yet, still put the code in `uhk-web` and add tests there; do not grow UI surface area in `uhk-common` for convenience. diff --git a/packages/uhk-common/src/util/find-macro-key-assignments.test.ts b/packages/uhk-common/src/util/find-macro-key-assignments.test.ts deleted file mode 100644 index 5a00ce745c5..00000000000 --- a/packages/uhk-common/src/util/find-macro-key-assignments.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it } from 'node:test'; - -import { Keymap, Layer, LayerName, Module, PlayMacroAction } from '../config-serializer/index.js'; -import { findMacroKeyAssignments } from './find-macro-key-assignments.js'; - -function createKeymapWithMacroAssignment( - abbreviation: string, - name: string, - layerId: LayerName, - moduleId: number, - keyId: number, - macroId: number -): Keymap { - const keyActions = []; - keyActions[keyId] = Object.assign(new PlayMacroAction(), { macroId }); - - const keymap = new Keymap(); - keymap.abbreviation = abbreviation; - keymap.name = name; - keymap.layers = [ - Object.assign(new Layer(), { - id: layerId, - modules: [ - Object.assign(new Module(), { - id: moduleId, - keyActions, - }), - ], - }), - ]; - - return keymap; -} - -describe('findMacroKeyAssignments', () => { - it('returns assignments for the requested macro', ({ assert }) => { - const keymaps = [ - createKeymapWithMacroAssignment('QWR', 'QWERTY for PC', LayerName.fn, 0, 2, 5), - createKeymapWithMacroAssignment('DVO', 'Dvorak', LayerName.base, 1, 0, 5), - createKeymapWithMacroAssignment('QWR', 'QWERTY for PC', LayerName.mod, 0, 1, 9), - ]; - - const assignments = findMacroKeyAssignments(keymaps, 5); - - assert.equal(assignments.length, 2); - assert.deepEqual(assignments[0], { - keymapAbbreviation: 'QWR', - keymapName: 'QWERTY for PC', - layerId: LayerName.fn, - moduleId: 0, - keyId: 2, - }); - assert.deepEqual(assignments[1], { - keymapAbbreviation: 'DVO', - keymapName: 'Dvorak', - layerId: LayerName.base, - moduleId: 1, - keyId: 0, - }); - }); - - it('returns an empty list when the macro is not assigned', ({ assert }) => { - const keymaps = [ - createKeymapWithMacroAssignment('QWR', 'QWERTY for PC', LayerName.fn, 0, 2, 5), - ]; - - assert.deepEqual(findMacroKeyAssignments(keymaps, 99), []); - }); -}); diff --git a/packages/uhk-common/src/util/index.ts b/packages/uhk-common/src/util/index.ts index 0a899808084..9917c00d7e2 100644 --- a/packages/uhk-common/src/util/index.ts +++ b/packages/uhk-common/src/util/index.ts @@ -6,7 +6,6 @@ export * from './convert-array-to-hex-string.js'; export * from './create-md5-hash.js'; export * from './date-formatter.js'; export * from './disable-agent-upgrade-protection.js'; -export * from './find-macro-key-assignments.js'; export * from './find-uhk-module-by-id.js'; export * from './get-formatted-timestamp.js'; export * from './get-md5-hash-from-file-name.js'; diff --git a/packages/uhk-web/src/app/components/back-to/back-to.component.ts b/packages/uhk-web/src/app/components/back-to/back-to.component.ts index 6101927d639..db1b52f8a46 100644 --- a/packages/uhk-web/src/app/components/back-to/back-to.component.ts +++ b/packages/uhk-web/src/app/components/back-to/back-to.component.ts @@ -26,7 +26,7 @@ export class BackToComponent implements OnDestroy { const backUrl = new URL(params.backUrl as string, window.location.origin); this.backUrl = backUrl.pathname; this.backText = params.backText; - this.backSuffix = params.backSuffix ?? ''; + this.backSuffix = params.backSuffix || ''; this.queryParams = {}; for (const key of backUrl.searchParams.keys()) { this.queryParams[key] = backUrl.searchParams.get(key); diff --git a/packages/uhk-web/src/app/components/macro/edit/macro-edit.component.html b/packages/uhk-web/src/app/components/macro/edit/macro-edit.component.html index a789ec2ee6a..7a749478c8b 100644 --- a/packages/uhk-web/src/app/components/macro/edit/macro-edit.component.html +++ b/packages/uhk-web/src/app/components/macro/edit/macro-edit.component.html @@ -10,6 +10,7 @@ [macro]="macro" [isNew]="isNew$ | async" [maxMacroCountReached]="maxMacroCountReached$ | async" + [assignments]="assignments" > ; macroId: number; macroPlaybackSupported$: Observable; @@ -64,12 +73,26 @@ export class MacroEditComponent implements OnDestroy { constructor(private store: Store, private cdRef: ChangeDetectorRef, private route: ActivatedRoute, - private router: Router) { - this.subscriptions.add(store.select(getSelectedMacro) - .subscribe((macro: Macro) => { + private router: Router, + private mapper: MapperService) { + this.subscriptions.add( + combineLatest([ + store.select(getSelectedMacro), + store.select(getKeymaps), + store.select(getDefaultUserConfiguration), + ]).subscribe(([macro, keymaps, defaultUserConfiguration]) => { this.macro = macro; + this.assignments = macro + ? buildMacroKeyAssignmentViewModels({ + keymaps, + macroId: macro.id, + defaultUserConfiguration, + mapper: this.mapper, + }) + : []; this.cdRef.markForCheck(); - })); + }) + ); this.isNew$ = this.store.select(isSelectedMacroNew); this.isMacroCommandSupported$ = this.store.select(isMacroCommandSupported); diff --git a/packages/uhk-web/src/app/components/macro/header/macro-header.component.html b/packages/uhk-web/src/app/components/macro/header/macro-header.component.html index 73dd2360b5d..1a548247d7a 100644 --- a/packages/uhk-web/src/app/components/macro/header/macro-header.component.html +++ b/packages/uhk-web/src/app/components/macro/header/macro-header.component.html @@ -18,11 +18,11 @@ placement="bottom-right" (click)="duplicateMacro()"> -
- Used on: +
+ Used on: - {{ assignment.label }}, + [queryParams]="{ layer: assignment.layerId, module: assignment.moduleId, key: assignment.keyId }">{{ assignment.label }},
diff --git a/packages/uhk-web/src/app/components/macro/header/macro-header.component.scss b/packages/uhk-web/src/app/components/macro/header/macro-header.component.scss index 99389b963fc..469b7e2e536 100644 --- a/packages/uhk-web/src/app/components/macro/header/macro-header.component.scss +++ b/packages/uhk-web/src/app/components/macro/header/macro-header.component.scss @@ -1,28 +1,4 @@ -:host { - display: block; -} - .macro { - &__assignments { - font-size: 0.875rem; - line-height: 1.5; - padding-top: 1.25em; - } - - &__assignments-label { - margin-right: 0.35em; - white-space: nowrap; - } - - &__assignment-link { - text-decoration: underline; - white-space: nowrap; - } - - &__assignment-separator { - white-space: pre; - } - &__remove { font-size: 0.75em; top: 7px; diff --git a/packages/uhk-web/src/app/components/macro/header/macro-header.component.ts b/packages/uhk-web/src/app/components/macro/header/macro-header.component.ts index 0314c1c9882..088e9f7a7a9 100644 --- a/packages/uhk-web/src/app/components/macro/header/macro-header.component.ts +++ b/packages/uhk-web/src/app/components/macro/header/macro-header.component.ts @@ -1,37 +1,20 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, Input, OnChanges, - OnDestroy, SimpleChanges, ViewChild } from '@angular/core'; import { Store } from '@ngrx/store'; import { faCopy, faPlay, faTrash } from '@fortawesome/free-solid-svg-icons'; -import { - findMacroKeyAssignments, - Keymap, - LayerName, - Macro, - MacroKeyAssignment, - MAX_ALLOWED_MACROS_TOOLTIP, - UserConfiguration -} from 'uhk-common'; -import { combineLatest, Subscription } from 'rxjs'; +import { Macro, MAX_ALLOWED_MACROS_TOOLTIP } from 'uhk-common'; import { DuplicateMacroAction, EditMacroNameAction, RemoveMacroAction } from '../../../store/actions/macro'; -import { AppState, getDefaultUserConfiguration, getKeymaps } from '../../../store'; +import { AppState } from '../../../store'; import { MacroKeyAssignmentViewModel } from '../../../models'; import * as util from '../../../util'; -import { getDefaultQwertyKeyLabel } from '../../../util/get-default-key-label'; import { AutoGrowInputComponent } from '../../auto-grow-input'; -import { initLayerOptions } from '../../../store/reducers/layer-options'; -import { MapperService } from '../../../services/mapper.service'; - -const MACRO_KEY_ASSIGNMENT_SEPARATOR = ' ⭢ '; -const LAYER_OPTIONS = initLayerOptions(); @Component({ selector: 'macro-header', @@ -40,58 +23,28 @@ const LAYER_OPTIONS = initLayerOptions(); styleUrls: ['./macro-header.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class MacroHeaderComponent implements OnChanges, OnDestroy { +export class MacroHeaderComponent implements OnChanges { @Input() macro: Macro; @Input() isNew: boolean; @Input() maxMacroCountReached: boolean; + @Input() assignments: MacroKeyAssignmentViewModel[] = []; @ViewChild(AutoGrowInputComponent, { static: true }) macroName: AutoGrowInputComponent; - assignments: MacroKeyAssignmentViewModel[] = []; - faCopy = faCopy; faPlay = faPlay; faTrash = faTrash; maxAllowedMacrosTooltip = MAX_ALLOWED_MACROS_TOOLTIP; - private keymaps: Keymap[] = []; - private defaultUserConfiguration: UserConfiguration; - private subscriptions = new Subscription(); - - constructor(private store: Store, - private mapper: MapperService, - private cdRef: ChangeDetectorRef) { - this.subscriptions.add( - combineLatest([ - this.store.select(getKeymaps), - this.store.select(getDefaultUserConfiguration), - ]).subscribe(([keymaps, defaultUserConfiguration]) => { - this.keymaps = keymaps; - this.defaultUserConfiguration = defaultUserConfiguration; - this.updateAssignments(); - }) - ); + constructor(private store: Store) { } ngOnChanges(changes: SimpleChanges): void { if (changes.macro) { this.macroName.writeValue(changes.macro.currentValue.name as string); - this.updateAssignments(); } } - ngOnDestroy(): void { - this.subscriptions.unsubscribe(); - } - - getKeymapQueryParams(assignment: MacroKeyAssignmentViewModel) { - return { - layer: assignment.layerId, - module: assignment.moduleId, - key: assignment.keyId, - }; - } - removeMacro() { this.store.dispatch(new RemoveMacroAction(this.macro.id)); } @@ -110,60 +63,4 @@ export class MacroHeaderComponent implements OnChanges, OnDestroy { this.store.dispatch(new EditMacroNameAction({ id: this.macro.id, name })); } - - private updateAssignments(): void { - if (!this.macro || !this.keymaps || !this.defaultUserConfiguration) { - this.assignments = []; - this.cdRef.markForCheck(); - return; - } - - const layerOptions = LAYER_OPTIONS; - this.assignments = findMacroKeyAssignments(this.keymaps, this.macro.id) - .sort((first, second) => this.compareAssignments(first, second, layerOptions)) - .map(assignment => { - const layerName = layerOptions.get(assignment.layerId)?.name ?? LayerName[assignment.layerId]; - const keyLabel = getDefaultQwertyKeyLabel( - this.defaultUserConfiguration, - assignment.moduleId, - assignment.keyId, - this.mapper - ); - - return { - keymapAbbreviation: assignment.keymapAbbreviation, - layerId: assignment.layerId, - moduleId: assignment.moduleId, - keyId: assignment.keyId, - label: `${assignment.keymapName}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${layerName}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${keyLabel}`, - }; - }); - - this.cdRef.markForCheck(); - } - - private compareAssignments( - first: MacroKeyAssignment, - second: MacroKeyAssignment, - layerOptions: ReturnType - ): number { - const keymapComparison = first.keymapName.localeCompare(second.keymapName); - - if (keymapComparison !== 0) { - return keymapComparison; - } - - const firstLayerOrder = layerOptions.get(first.layerId)?.order ?? 0; - const secondLayerOrder = layerOptions.get(second.layerId)?.order ?? 0; - - if (firstLayerOrder !== secondLayerOrder) { - return firstLayerOrder - secondLayerOrder; - } - - if (first.moduleId !== second.moduleId) { - return first.moduleId - second.moduleId; - } - - return first.keyId - second.keyId; - } } diff --git a/packages/uhk-web/src/app/util/build-macro-key-assignment-view-models.ts b/packages/uhk-web/src/app/util/build-macro-key-assignment-view-models.ts new file mode 100644 index 00000000000..a99eedce80e --- /dev/null +++ b/packages/uhk-web/src/app/util/build-macro-key-assignment-view-models.ts @@ -0,0 +1,67 @@ +import { Keymap, LayerName, UserConfiguration } from 'uhk-common'; + +import { MacroKeyAssignmentViewModel } from '../models'; +import { MapperService } from '../services/mapper.service'; +import { initLayerOptions } from '../store/reducers/layer-options'; +import { findMacroKeyAssignments, MacroKeyAssignment } from './find-macro-key-assignments'; +import { getDefaultQwertyKeyLabel } from './get-default-key-label'; + +const MACRO_KEY_ASSIGNMENT_SEPARATOR = ' ⭢ '; +const LAYER_OPTIONS = initLayerOptions(); + +export interface BuildMacroKeyAssignmentViewModelsOptions { + keymaps: Keymap[]; + macroId: number; + defaultUserConfiguration: UserConfiguration; + mapper: MapperService; +} + +export function buildMacroKeyAssignmentViewModels( + options: BuildMacroKeyAssignmentViewModelsOptions +): MacroKeyAssignmentViewModel[] { + return findMacroKeyAssignments(options.keymaps, options.macroId) + .sort(compareAssignments) + .map(assignment => { + const layerOption = LAYER_OPTIONS.get(assignment.layerId); + const layerName = layerOption + ? layerOption.name + : LayerName[assignment.layerId]; + const keyLabel = getDefaultQwertyKeyLabel({ + defaultUserConfiguration: options.defaultUserConfiguration, + moduleId: assignment.moduleId, + keyId: assignment.keyId, + mapper: options.mapper, + }); + + return { + keymapAbbreviation: assignment.keymapAbbreviation, + layerId: assignment.layerId, + moduleId: assignment.moduleId, + keyId: assignment.keyId, + label: `${assignment.keymapName}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${layerName}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${keyLabel}`, + }; + }); +} + +function compareAssignments(first: MacroKeyAssignment, second: MacroKeyAssignment): number { + const keymapComparison = first.keymapName.localeCompare(second.keymapName); + + if (keymapComparison !== 0) { + return keymapComparison; + } + + const firstLayerOption = LAYER_OPTIONS.get(first.layerId); + const secondLayerOption = LAYER_OPTIONS.get(second.layerId); + const firstLayerOrder = firstLayerOption ? firstLayerOption.order : 0; + const secondLayerOrder = secondLayerOption ? secondLayerOption.order : 0; + + if (firstLayerOrder !== secondLayerOrder) { + return firstLayerOrder - secondLayerOrder; + } + + if (first.moduleId !== second.moduleId) { + return first.moduleId - second.moduleId; + } + + return first.keyId - second.keyId; +} diff --git a/packages/uhk-web/src/app/util/find-macro-key-assignments.test.ts b/packages/uhk-web/src/app/util/find-macro-key-assignments.test.ts new file mode 100644 index 00000000000..ac15f4231f9 --- /dev/null +++ b/packages/uhk-web/src/app/util/find-macro-key-assignments.test.ts @@ -0,0 +1,100 @@ +import { describe, it } from 'node:test'; + +import { Keymap, Layer, LayerName, Module, PlayMacroAction } from 'uhk-common'; + +import { findMacroKeyAssignments } from './find-macro-key-assignments'; + +interface CreateKeymapWithMacroAssignmentOptions { + abbreviation: string; + name: string; + layerId: LayerName; + moduleId: number; + keyId: number; + macroId: number; +} + +function createKeymapWithMacroAssignment(options: CreateKeymapWithMacroAssignmentOptions): Keymap { + const keyActions = []; + keyActions[options.keyId] = Object.assign(new PlayMacroAction(), { macroId: options.macroId }); + + const keymap = new Keymap(); + keymap.abbreviation = options.abbreviation; + keymap.name = options.name; + keymap.layers = [ + Object.assign(new Layer(), { + id: options.layerId, + modules: [ + Object.assign(new Module(), { + id: options.moduleId, + keyActions, + }), + ], + }), + ]; + + return keymap; +} + +describe('findMacroKeyAssignments', () => { + it('returns assignments for the requested macro', ({ assert }) => { + const keymaps = [ + createKeymapWithMacroAssignment({ + abbreviation: 'QWR', + name: 'QWERTY for PC', + layerId: LayerName.fn, + moduleId: 0, + keyId: 2, + macroId: 5, + }), + createKeymapWithMacroAssignment({ + abbreviation: 'DVO', + name: 'Dvorak', + layerId: LayerName.base, + moduleId: 1, + keyId: 0, + macroId: 5, + }), + createKeymapWithMacroAssignment({ + abbreviation: 'QWR', + name: 'QWERTY for PC', + layerId: LayerName.mod, + moduleId: 0, + keyId: 1, + macroId: 9, + }), + ]; + + const assignments = findMacroKeyAssignments(keymaps, 5); + + assert.equal(assignments.length, 2); + assert.deepEqual(assignments[0], { + keymapAbbreviation: 'QWR', + keymapName: 'QWERTY for PC', + layerId: LayerName.fn, + moduleId: 0, + keyId: 2, + }); + assert.deepEqual(assignments[1], { + keymapAbbreviation: 'DVO', + keymapName: 'Dvorak', + layerId: LayerName.base, + moduleId: 1, + keyId: 0, + }); + }); + + it('returns an empty list when the macro is not assigned', ({ assert }) => { + const keymaps = [ + createKeymapWithMacroAssignment({ + abbreviation: 'QWR', + name: 'QWERTY for PC', + layerId: LayerName.fn, + moduleId: 0, + keyId: 2, + macroId: 5, + }), + ]; + + assert.deepEqual(findMacroKeyAssignments(keymaps, 99), []); + }); +}); diff --git a/packages/uhk-common/src/util/find-macro-key-assignments.ts b/packages/uhk-web/src/app/util/find-macro-key-assignments.ts similarity index 93% rename from packages/uhk-common/src/util/find-macro-key-assignments.ts rename to packages/uhk-web/src/app/util/find-macro-key-assignments.ts index dc64a368145..f955ffee9cb 100644 --- a/packages/uhk-common/src/util/find-macro-key-assignments.ts +++ b/packages/uhk-web/src/app/util/find-macro-key-assignments.ts @@ -1,4 +1,4 @@ -import { Keymap, LayerName, PlayMacroAction } from '../config-serializer/index.js'; +import { Keymap, LayerName, PlayMacroAction } from 'uhk-common'; export interface MacroKeyAssignment { keymapAbbreviation: string; diff --git a/packages/uhk-web/src/app/util/get-default-key-label.ts b/packages/uhk-web/src/app/util/get-default-key-label.ts index 48c1ca84529..5f454a93737 100644 --- a/packages/uhk-web/src/app/util/get-default-key-label.ts +++ b/packages/uhk-web/src/app/util/get-default-key-label.ts @@ -4,43 +4,45 @@ import { MapperService } from '../services/mapper.service'; export const DEFAULT_QWERTY_KEYMAP_ABBREVIATION = 'QWR'; -export function getDefaultQwertyKeyLabel( - defaultUserConfiguration: UserConfiguration, - moduleId: number, - keyId: number, - mapper: MapperService -): string { - const qwertyKeymap = defaultUserConfiguration.keymaps +export interface GetDefaultQwertyKeyLabelOptions { + defaultUserConfiguration: UserConfiguration; + moduleId: number; + keyId: number; + mapper: MapperService; +} + +export function getDefaultQwertyKeyLabel(options: GetDefaultQwertyKeyLabelOptions): string { + const qwertyKeymap = options.defaultUserConfiguration.keymaps .find(keymap => keymap.abbreviation === DEFAULT_QWERTY_KEYMAP_ABBREVIATION); if (!qwertyKeymap) { - return fallbackKeyLabel(keyId); + return fallbackKeyLabel(options.keyId); } const baseLayer = qwertyKeymap.layers.find(layer => layer.id === LayerName.base); if (!baseLayer) { - return fallbackKeyLabel(keyId); + return fallbackKeyLabel(options.keyId); } - const module = baseLayer.modules.find(moduleConfig => moduleConfig.id === moduleId); + const module = baseLayer.modules.find(moduleConfig => moduleConfig.id === options.moduleId); if (!module) { - return fallbackKeyLabel(keyId); + return fallbackKeyLabel(options.keyId); } - const keyAction = module.keyActions[keyId]; + const keyAction = module.keyActions[options.keyId]; if (!(keyAction instanceof KeystrokeAction)) { - return fallbackKeyLabel(keyId); + return fallbackKeyLabel(options.keyId); } - return formatKeystrokeLabel(keyAction, mapper); + return formatKeystrokeLabel(keyAction, options.mapper); } function formatKeystrokeLabel(keystrokeAction: KeystrokeAction, mapper: MapperService): string { if (!keystrokeAction.hasScancode()) { - return fallbackKeyLabel(); + return 'Key'; } const labelParts = mapper.scanCodeToText(keystrokeAction.scancode, keystrokeAction.type); @@ -49,13 +51,13 @@ function formatKeystrokeLabel(keystrokeAction: KeystrokeAction, mapper: MapperSe return labelParts[0]; } - if (labelParts[1]?.startsWith('icon')) { + if (labelParts.length > 1 && labelParts[1].startsWith('icon')) { return labelParts[0]; } return labelParts.join(' '); } -function fallbackKeyLabel(keyId?: number): string { - return keyId === undefined ? 'Key' : `Key ${keyId}`; +function fallbackKeyLabel(keyId: number): string { + return `Key ${keyId}`; } From 6deda762aaf5acca74de8c70941f873fb03bec76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Wed, 15 Jul 2026 21:15:50 +0200 Subject: [PATCH 3/5] Tweak Used on spacing and keep macro popover controls on one line. Co-authored-by: Cursor --- .../app/components/macro/header/macro-header.component.html | 2 +- .../app/components/popover/tab/macro/macro-tab.component.scss | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/uhk-web/src/app/components/macro/header/macro-header.component.html b/packages/uhk-web/src/app/components/macro/header/macro-header.component.html index 1a548247d7a..34de6eba06a 100644 --- a/packages/uhk-web/src/app/components/macro/header/macro-header.component.html +++ b/packages/uhk-web/src/app/components/macro/header/macro-header.component.html @@ -18,7 +18,7 @@ placement="bottom-right" (click)="duplicateMacro()"> -
+
Used on: Date: Thu, 16 Jul 2026 06:44:14 +0200 Subject: [PATCH 4/5] fix: fine tuning --- packages/uhk-web/package.json | 4 +++- .../uhk-web/src/app/store/effects/user-config.ts | 3 ++- .../src/app/store/reducers/layer-options.ts | 8 ++++++++ .../util/build-macro-key-assignment-view-models.ts | 14 ++++---------- .../app/util/find-macro-key-assignments.test.ts | 2 +- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/uhk-web/package.json b/packages/uhk-web/package.json index f84d99a0692..51967a0a378 100644 --- a/packages/uhk-web/package.json +++ b/packages/uhk-web/package.json @@ -11,9 +11,11 @@ "server:renderer": "ng build --project=uhk-renderer --watch", "lint": "run-p -snc lint:*", "lint:ng": "ng lint", - "lint:style": "stylelint \"src/**/*.scss\"" + "lint:style": "stylelint \"src/**/*.scss\"", + "test": "node --loader=ts-node/esm --test \"**/*.test.ts\"" }, "private": true, + "type": "module", "license": "See in LICENSE", "devDependencies": { "@angular-devkit/build-angular": "20.3.28", diff --git a/packages/uhk-web/src/app/store/effects/user-config.ts b/packages/uhk-web/src/app/store/effects/user-config.ts index 94c29343fcb..ad7057505b5 100644 --- a/packages/uhk-web/src/app/store/effects/user-config.ts +++ b/packages/uhk-web/src/app/store/effects/user-config.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Actions, createEffect, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects'; import { routerNavigatedAction, RouterNavigatedAction } from '@ngrx/router-store'; +import { Action } from '@ngrx/store'; import { Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap, withLatestFrom, } from 'rxjs/operators'; import { Store } from '@ngrx/store'; @@ -468,7 +469,7 @@ export class UserConfigEffects { } } -function isNavigateToMacroSaveKey(action: { type: string }): boolean { +function isNavigateToMacroSaveKey(action: Action): boolean { if (action.type !== Keymaps.ActionTypes.SaveKey) { return false; } diff --git a/packages/uhk-web/src/app/store/reducers/layer-options.ts b/packages/uhk-web/src/app/store/reducers/layer-options.ts index 15d65ab9a6c..1c89c89fb9e 100644 --- a/packages/uhk-web/src/app/store/reducers/layer-options.ts +++ b/packages/uhk-web/src/app/store/reducers/layer-options.ts @@ -23,3 +23,11 @@ export function initLayerOptions(): Map { return layerOptions; } + +// TODO: use LAYER_OPTIONS where don't mutate the initLayerOptions +/** + * Use only for lookup LayerOption. If you need to mutate LayerOptions, use {@link initLayerOptions}. + * This map always contains all LayerOptions. If someone tries to load a newer user configuration that Agent + * can't parse then no code will run that uses this map. + */ +export const LAYER_OPTIONS = Object.freeze(initLayerOptions()); diff --git a/packages/uhk-web/src/app/util/build-macro-key-assignment-view-models.ts b/packages/uhk-web/src/app/util/build-macro-key-assignment-view-models.ts index a99eedce80e..c2104b2e9c2 100644 --- a/packages/uhk-web/src/app/util/build-macro-key-assignment-view-models.ts +++ b/packages/uhk-web/src/app/util/build-macro-key-assignment-view-models.ts @@ -2,12 +2,11 @@ import { Keymap, LayerName, UserConfiguration } from 'uhk-common'; import { MacroKeyAssignmentViewModel } from '../models'; import { MapperService } from '../services/mapper.service'; -import { initLayerOptions } from '../store/reducers/layer-options'; +import { LAYER_OPTIONS } from '../store/reducers/layer-options'; import { findMacroKeyAssignments, MacroKeyAssignment } from './find-macro-key-assignments'; import { getDefaultQwertyKeyLabel } from './get-default-key-label'; const MACRO_KEY_ASSIGNMENT_SEPARATOR = ' ⭢ '; -const LAYER_OPTIONS = initLayerOptions(); export interface BuildMacroKeyAssignmentViewModelsOptions { keymaps: Keymap[]; @@ -23,9 +22,6 @@ export function buildMacroKeyAssignmentViewModels( .sort(compareAssignments) .map(assignment => { const layerOption = LAYER_OPTIONS.get(assignment.layerId); - const layerName = layerOption - ? layerOption.name - : LayerName[assignment.layerId]; const keyLabel = getDefaultQwertyKeyLabel({ defaultUserConfiguration: options.defaultUserConfiguration, moduleId: assignment.moduleId, @@ -38,7 +34,7 @@ export function buildMacroKeyAssignmentViewModels( layerId: assignment.layerId, moduleId: assignment.moduleId, keyId: assignment.keyId, - label: `${assignment.keymapName}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${layerName}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${keyLabel}`, + label: `${assignment.keymapName}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${layerOption.name}${MACRO_KEY_ASSIGNMENT_SEPARATOR}${keyLabel}`, }; }); } @@ -50,10 +46,8 @@ function compareAssignments(first: MacroKeyAssignment, second: MacroKeyAssignmen return keymapComparison; } - const firstLayerOption = LAYER_OPTIONS.get(first.layerId); - const secondLayerOption = LAYER_OPTIONS.get(second.layerId); - const firstLayerOrder = firstLayerOption ? firstLayerOption.order : 0; - const secondLayerOrder = secondLayerOption ? secondLayerOption.order : 0; + const firstLayerOrder = LAYER_OPTIONS.get(first.layerId).order; + const secondLayerOrder = LAYER_OPTIONS.get(second.layerId).order; if (firstLayerOrder !== secondLayerOrder) { return firstLayerOrder - secondLayerOrder; diff --git a/packages/uhk-web/src/app/util/find-macro-key-assignments.test.ts b/packages/uhk-web/src/app/util/find-macro-key-assignments.test.ts index ac15f4231f9..ca845ff5c1c 100644 --- a/packages/uhk-web/src/app/util/find-macro-key-assignments.test.ts +++ b/packages/uhk-web/src/app/util/find-macro-key-assignments.test.ts @@ -2,7 +2,7 @@ import { describe, it } from 'node:test'; import { Keymap, Layer, LayerName, Module, PlayMacroAction } from 'uhk-common'; -import { findMacroKeyAssignments } from './find-macro-key-assignments'; +import { findMacroKeyAssignments } from './find-macro-key-assignments.js'; interface CreateKeymapWithMacroAssignmentOptions { abbreviation: string; From 4932d07ac75e6753165746ef343c17bf4d4534c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Thu, 16 Jul 2026 13:00:32 +0200 Subject: [PATCH 5/5] Load default keymap on app start for macro key labels. The macro page "Used on" links need the default QWERTY keymap, which was previously only loaded when opening the add-keymap page. Co-authored-by: Cursor --- .../default-user-configuration.effect.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/uhk-web/src/app/store/effects/default-user-configuration.effect.ts b/packages/uhk-web/src/app/store/effects/default-user-configuration.effect.ts index 2cd08bf1a82..498c1ceae6e 100644 --- a/packages/uhk-web/src/app/store/effects/default-user-configuration.effect.ts +++ b/packages/uhk-web/src/app/store/effects/default-user-configuration.effect.ts @@ -2,20 +2,40 @@ import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store'; import { Store } from '@ngrx/store'; -import { distinctUntilChanged, filter, map, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, startWith, withLatestFrom } from 'rxjs/operators'; import { UHK_80_DEVICE } from 'uhk-common'; +import * as AppActions from '../actions/app'; import { ActionTypes, AddKeymapSelectedAction, LoadDefaultUserConfigurationAction, LoadDefaultUserConfigurationSuccessAction } from '../actions/default-user-configuration.actions'; +import * as Device from '../actions/device'; import { DefaultUserConfigurationService } from '../../services/default-user-configuration.service'; import { AppState, getConnectedDevice } from '../index'; import { RouterState } from '../router-util'; @Injectable() export class DefaultUserConfigurationEffect { + loadDefaultUserConfigurationOnAppStart$ = createEffect(() => this.actions$ + .pipe( + ofType(AppActions.ActionTypes.AppBootstrapped), + startWith(new AppActions.AppStartedAction()), + map(() => new LoadDefaultUserConfigurationAction()) + ) + ); + + reloadDefaultUserConfigurationOnDeviceChange$ = createEffect(() => this.actions$ + .pipe( + ofType(Device.ActionTypes.ConnectionStateChanged), + withLatestFrom(this.store.select(getConnectedDevice)), + map(([, connectedDevice]) => connectedDevice?.id ?? null), + distinctUntilChanged(), + map(() => new LoadDefaultUserConfigurationAction()) + ) + ); + loadDefaultUserConfiguration$ = createEffect(() => this.actions$ .pipe( ofType(ActionTypes.LoadDefaultUserConfiguration),