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-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/components/back-to/back-to.component.ts b/packages/uhk-web/src/app/components/back-to/back-to.component.ts
index eec3d5fae9b..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
@@ -7,13 +7,14 @@ import { Subscription } from 'rxjs';
standalone: false,
template: `
`,
})
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.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;
@@ -59,20 +68,31 @@ export class MacroEditComponent implements OnDestroy {
left: 100,
right: 0
};
-
- private backUrl: string;
- private backUrlText: string;
private subscriptions = new Subscription();
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);
@@ -90,9 +110,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 +167,6 @@ export class MacroEditComponent implements OnDestroy {
this.router.navigate([], {
queryParams: {
actionIndex: model?.id,
- backText: this.backUrlText,
- backUrl: this.backUrl,
inlineEdit: model?.inlineEdit
}
});
@@ -173,10 +188,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..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,4 +18,11 @@
placement="bottom-right"
(click)="duplicateMacro()">
-
+
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..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,4 +1,3 @@
-
.macro {
&__remove {
font-size: 0.75em;
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..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
@@ -12,6 +12,7 @@ import { Macro, MAX_ALLOWED_MACROS_TOOLTIP } from 'uhk-common';
import { DuplicateMacroAction, EditMacroNameAction, RemoveMacroAction } from '../../../store/actions/macro';
import { AppState } from '../../../store';
+import { MacroKeyAssignmentViewModel } from '../../../models';
import * as util from '../../../util';
import { AutoGrowInputComponent } from '../../auto-grow-input';
@@ -26,6 +27,7 @@ export class MacroHeaderComponent implements OnChanges {
@Input() macro: Macro;
@Input() isNew: boolean;
@Input() maxMacroCountReached: boolean;
+ @Input() assignments: MacroKeyAssignmentViewModel[] = [];
@ViewChild(AutoGrowInputComponent, { static: true }) macroName: AutoGrowInputComponent;
diff --git a/packages/uhk-web/src/app/components/popover/tab/macro/macro-tab.component.scss b/packages/uhk-web/src/app/components/popover/tab/macro/macro-tab.component.scss
index 91db4697b95..9acc66ae5c6 100644
--- a/packages/uhk-web/src/app/components/popover/tab/macro/macro-tab.component.scss
+++ b/packages/uhk-web/src/app/components/popover/tab/macro/macro-tab.component.scss
@@ -26,7 +26,9 @@
}
.col-controls {
- min-width: 130px;
+ min-width: 145px;
+ text-align: right;
+ white-space: nowrap;
&.arguments {
fa-icon {
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/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),
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..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';
@@ -121,6 +122,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 +378,7 @@ export class UserConfigEffects {
[module.configPath],
{
queryParams: {
+ backSuffix: action.payload.backSuffix,
backText: action.payload.backText,
backUrl: action.payload.backUrl,
},
@@ -466,6 +469,15 @@ export class UserConfigEffects {
}
}
+function isNavigateToMacroSaveKey(action: Action): 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/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/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/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..c2104b2e9c2
--- /dev/null
+++ b/packages/uhk-web/src/app/util/build-macro-key-assignment-view-models.ts
@@ -0,0 +1,61 @@
+import { Keymap, LayerName, UserConfiguration } from 'uhk-common';
+
+import { MacroKeyAssignmentViewModel } from '../models';
+import { MapperService } from '../services/mapper.service';
+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 = ' ⭢ ';
+
+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 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}${layerOption.name}${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 firstLayerOrder = LAYER_OPTIONS.get(first.layerId).order;
+ const secondLayerOrder = LAYER_OPTIONS.get(second.layerId).order;
+
+ 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..ca845ff5c1c
--- /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.js';
+
+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-web/src/app/util/find-macro-key-assignments.ts b/packages/uhk-web/src/app/util/find-macro-key-assignments.ts
new file mode 100644
index 00000000000..f955ffee9cb
--- /dev/null
+++ b/packages/uhk-web/src/app/util/find-macro-key-assignments.ts
@@ -0,0 +1,37 @@
+import { Keymap, LayerName, PlayMacroAction } from 'uhk-common';
+
+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-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..5f454a93737
--- /dev/null
+++ b/packages/uhk-web/src/app/util/get-default-key-label.ts
@@ -0,0 +1,63 @@
+import { KeystrokeAction, LayerName, UserConfiguration } from 'uhk-common';
+
+import { MapperService } from '../services/mapper.service';
+
+export const DEFAULT_QWERTY_KEYMAP_ABBREVIATION = 'QWR';
+
+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(options.keyId);
+ }
+
+ const baseLayer = qwertyKeymap.layers.find(layer => layer.id === LayerName.base);
+
+ if (!baseLayer) {
+ return fallbackKeyLabel(options.keyId);
+ }
+
+ const module = baseLayer.modules.find(moduleConfig => moduleConfig.id === options.moduleId);
+
+ if (!module) {
+ return fallbackKeyLabel(options.keyId);
+ }
+
+ const keyAction = module.keyActions[options.keyId];
+
+ if (!(keyAction instanceof KeystrokeAction)) {
+ return fallbackKeyLabel(options.keyId);
+ }
+
+ return formatKeystrokeLabel(keyAction, options.mapper);
+}
+
+function formatKeystrokeLabel(keystrokeAction: KeystrokeAction, mapper: MapperService): string {
+ if (!keystrokeAction.hasScancode()) {
+ return 'Key';
+ }
+
+ const labelParts = mapper.scanCodeToText(keystrokeAction.scancode, keystrokeAction.type);
+
+ if (labelParts.length === 1) {
+ return labelParts[0];
+ }
+
+ if (labelParts.length > 1 && labelParts[1].startsWith('icon')) {
+ return labelParts[0];
+ }
+
+ return labelParts.join(' ');
+}
+
+function fallbackKeyLabel(keyId: number): string {
+ return `Key ${keyId}`;
+}