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: `
- 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.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()"> - +
+ 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..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}`; +}