From 7925546fcd6d5a01c94ded0d63b03f4d524bffc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Tue, 7 Jul 2026 22:10:35 +0200 Subject: [PATCH 1/2] feat: improve firmware screen with per-device progress, cable warning, and toggleable log - Show a determinate per-device progress bar with live percentage during firmware flashing, wired from the mcumgr/kboot write operations through a new moduleFirmwareUpgradeProgress IPC event. - Surface the "connect the left/right half via USB" prompt as a prominent, easily-noticeable warning on the firmware screen via a dedicated IPC signal instead of relying on the update log. - Hide the firmware update log by default and add a Show/Hide toggle that appears once the log has any text. Implements UltimateHackingKeyboard/agent#2593. Co-authored-by: Cursor --- packages/kboot/src/kboot.ts | 4 +- packages/kboot/src/peripheral.ts | 2 +- packages/kboot/src/usb-peripheral.ts | 4 +- packages/kboot/test/test-peripheral.ts | 3 +- packages/mcumgr/src/mcumgr.ts | 4 +- .../uhk-agent/src/services/device.service.ts | 66 +++++++++++++---- .../models/firmware-upgrade-connect-prompt.ts | 3 + packages/uhk-common/src/models/index.ts | 2 + .../module-firmware-upgrade-progress.ts | 4 + packages/uhk-common/src/util/ipcEvents.ts | 2 + packages/uhk-usb/src/uhk-operations.ts | 30 +++++--- .../firmware/device-firmware.component.html | 73 +++++++++++++------ .../firmware/device-firmware.component.scss | 52 +++++++++++++ .../firmware/device-firmware.component.ts | 15 +++- .../src/app/models/firmware-upgrade-state.ts | 2 + .../app/services/device-renderer.service.ts | 12 +++ .../uhk-web/src/app/store/actions/device.ts | 20 +++++ .../reducers/firmware-upgrade.reducer.ts | 39 ++++++++++ 18 files changed, 281 insertions(+), 56 deletions(-) create mode 100644 packages/uhk-common/src/models/firmware-upgrade-connect-prompt.ts create mode 100644 packages/uhk-common/src/models/module-firmware-upgrade-progress.ts diff --git a/packages/kboot/src/kboot.ts b/packages/kboot/src/kboot.ts index 8e45df2c709..0294df62167 100644 --- a/packages/kboot/src/kboot.ts +++ b/packages/kboot/src/kboot.ts @@ -151,9 +151,9 @@ export class KBoot { return this.peripheral.readMemory(startAddress, count); } - async writeMemory(options: DataOption): Promise { + async writeMemory(options: DataOption, onProgress?: (percent: number) => void): Promise { logger('Start write memory %o', { options }); - return this.peripheral.writeMemory(options); + return this.peripheral.writeMemory(options, onProgress); } /** diff --git a/packages/kboot/src/peripheral.ts b/packages/kboot/src/peripheral.ts index 3e5def0fda3..3ec9b91fcca 100644 --- a/packages/kboot/src/peripheral.ts +++ b/packages/kboot/src/peripheral.ts @@ -7,7 +7,7 @@ export interface Peripheral { sendCommand(options: CommandOption): Promise; - writeMemory(data: DataOption): Promise; + writeMemory(data: DataOption, onProgress?: (percent: number) => void): Promise; readMemory(startAddress: number, count: number): Promise; } diff --git a/packages/kboot/src/usb-peripheral.ts b/packages/kboot/src/usb-peripheral.ts index ad289853a25..831f7f16576 100644 --- a/packages/kboot/src/usb-peripheral.ts +++ b/packages/kboot/src/usb-peripheral.ts @@ -75,9 +75,10 @@ export class UsbPeripheral implements Peripheral { }); } - writeMemory(option: DataOption): Promise { + writeMemory(option: DataOption, onProgress?: (percent: number) => void): Promise { return new Promise(async (resolve, reject) => { try { + onProgress?.(0); const command: CommandOption = { command: Commands.WriteMemory, hasDataPhase: true, @@ -113,6 +114,7 @@ export class UsbPeripheral implements Peripheral { await this._device.write(writeData); // workaround to prevent main thread blocking await snooze(1); + onProgress?.(Math.min(100, Math.round((i + slice.length) / option.data.length * 100))); } const receivedData = await this._device.read(option.timeout || 2000); diff --git a/packages/kboot/test/test-peripheral.ts b/packages/kboot/test/test-peripheral.ts index 410995afc10..d7ae9372751 100644 --- a/packages/kboot/test/test-peripheral.ts +++ b/packages/kboot/test/test-peripheral.ts @@ -17,7 +17,8 @@ export class TestPeripheral implements Peripheral { return Promise.resolve(response); } - writeMemory(data: DataOption): Promise { + writeMemory(data: DataOption, onProgress?: (percent: number) => void): Promise { + onProgress?.(100); return Promise.resolve(); } diff --git a/packages/mcumgr/src/mcumgr.ts b/packages/mcumgr/src/mcumgr.ts index 867cc0ee091..1293ae7ecb0 100644 --- a/packages/mcumgr/src/mcumgr.ts +++ b/packages/mcumgr/src/mcumgr.ts @@ -74,9 +74,10 @@ export class McuManager { /** * Upload a firmware/bootloader image to the device */ - async imageUpload(buffer: Buffer): Promise { + async imageUpload(buffer: Buffer, onProgress?: (percent: number) => void): Promise { logger('Start send image upload command: %o', { bufferLength: buffer.byteLength }); let written = 0; + onProgress?.(0); while (written < buffer.length) { const message: ImageUploadRequest = { @@ -104,6 +105,7 @@ export class McuManager { } logger('Image uploaded: %d', written / buffer.length * 100); + onProgress?.(Math.min(100, Math.round(written / buffer.length * 100))); } } diff --git a/packages/uhk-agent/src/services/device.service.ts b/packages/uhk-agent/src/services/device.service.ts index ad9f2f647dd..af4d1b0c6d4 100644 --- a/packages/uhk-agent/src/services/device.service.ts +++ b/packages/uhk-agent/src/services/device.service.ts @@ -16,6 +16,7 @@ import { escapeZephyrControlChars, findUhkModuleById, FIRMWARE_UPGRADE_METHODS, + FirmwareUpgradeConnectPrompt, FirmwareUpgradeIpcResponse, getHardwareConfigFromDeviceResponse, getUserConfigFromDeviceResponse, @@ -32,6 +33,7 @@ import { LeftSlotModules, LogService, mapObjectToUserConfigBinaryBuffer, + ModuleFirmwareUpgradeProgress, ModuleFirmwareUpgradeSkipInfo, ModuleFirmwareUpgradeSkipReason, ModuleInfo, @@ -499,6 +501,24 @@ export class DeviceService { let shouldEnableFirmwareVersionCheck = false; let uhkDeviceProduct: UhkDeviceProduct; + const createFirmwareProgressReporter = (moduleName: string): ((percent: number) => void) => { + let lastProgress = -1; + + return (percent: number) => { + const progress = Math.max(0, Math.min(100, Math.round(percent))); + + if (progress === lastProgress) { + return; + } + + lastProgress = progress; + event.sender.send(IpcEvents.device.moduleFirmwareUpgradeProgress, { + moduleName, + progress, + } as ModuleFirmwareUpgradeProgress); + }; + }; + try { await this.stopPollUhkDevice(); await this.dongleZephyrLogService.disable(); @@ -544,7 +564,7 @@ export class DeviceService { moduleName: UHK_DONGLE.name, newFirmwareChecksum: deviceConfig.md5, } as CurrentlyUpdatingModuleInfo); - await dongleOperations.updateDeviceFirmware(dongleFirmwarePath, UHK_DONGLE); + await dongleOperations.updateDeviceFirmware(dongleFirmwarePath, UHK_DONGLE, createFirmwareProgressReporter(UHK_DONGLE.name)); this.logService.misc('[DeviceService] Waiting for keyboard'); await waitForDevices(UHK_DONGLE.keyboard); await dongleUhkDevice.close(); @@ -593,7 +613,7 @@ export class DeviceService { newFirmwareChecksum: deviceConfig.md5, moduleName: RIGHT_HALF_FIRMWARE_UPGRADE_MODULE_NAME, } as CurrentlyUpdatingModuleInfo); - await this.operations.updateDeviceFirmware(deviceFirmwarePath, uhkDeviceProduct); + await this.operations.updateDeviceFirmware(deviceFirmwarePath, uhkDeviceProduct, createFirmwareProgressReporter(RIGHT_HALF_FIRMWARE_UPGRADE_MODULE_NAME)); this.logService.misc('[DeviceService] Waiting for keyboard'); await waitForDevices(uhkDeviceProduct.keyboard); @@ -657,27 +677,29 @@ export class DeviceService { } as CurrentlyUpdatingModuleInfo); if(uhkDeviceProduct.firmwareUpgradeMethod === FIRMWARE_UPGRADE_METHODS.MCUBOOT) { - if (!(await isUhkDeviceConnected(UHK_80_DEVICE_LEFT))) { - this.logService.misc('[DeviceService] To continue the firmware upgrade, now connect the left half via USB. (You can disconnect the right half or use a second USB cable.)'); - } + await this.waitForUhkDeviceWithConnectPrompt( + event, + UHK_80_DEVICE_LEFT, + 'To continue the firmware upgrade, now connect the left half via USB. (You can disconnect the right half or use a second USB cable.)' + ); - await waitForUhkDeviceConnected(UHK_80_DEVICE_LEFT); await snooze(1000); const firmwarePath = getDeviceFirmwarePath(UHK_80_DEVICE_LEFT, packageJson); - await this.operations.updateFirmwareWithMcuManager(firmwarePath, UHK_80_DEVICE_LEFT); - - if (!(await isUhkDeviceConnected(uhkDeviceProduct))) { - this.logService.misc('[DeviceService] To finish the firmware upgrade, now connect the right half via USB. (You can disconnect the left half or use a second USB cable.)'); - } + await this.operations.updateFirmwareWithMcuManager(firmwarePath, UHK_80_DEVICE_LEFT, createFirmwareProgressReporter(leftModuleInfo.module.name)); - await waitForUhkDeviceConnected(uhkDeviceProduct); + await this.waitForUhkDeviceWithConnectPrompt( + event, + uhkDeviceProduct, + 'To finish the firmware upgrade, now connect the right half via USB. (You can disconnect the left half or use a second USB cable.)' + ); } else { await this.operations .updateModuleWithKboot( getModuleFirmwarePath(leftModuleInfo.module, packageJson), uhkDeviceProduct, - leftModuleInfo.module + leftModuleInfo.module, + createFirmwareProgressReporter(leftModuleInfo.module.name) ); } } else { @@ -739,7 +761,8 @@ export class DeviceService { .updateModuleWithKboot( getModuleFirmwarePath(moduleInfo.module, packageJson), uhkDeviceProduct, - moduleInfo.module + moduleInfo.module, + createFirmwareProgressReporter(moduleInfo.module.name) ); this.logService.misc(`[DeviceService] "${moduleInfo.module.name}" firmware update done.`); } else { @@ -799,9 +822,24 @@ export class DeviceService { await this.dongleZephyrLogService.enable(); await this.leftHalfZephyrLogService.enable(); + event.sender.send(IpcEvents.device.firmwareUpgradeConnectPrompt, null); event.sender.send(IpcEvents.device.updateFirmwareReply, response); } + private async waitForUhkDeviceWithConnectPrompt( + event: Electron.IpcMainEvent, + device: UhkDeviceProduct, + message: string + ): Promise { + if (!(await isUhkDeviceConnected(device))) { + this.logService.misc(`[DeviceService] ${message}`); + event.sender.send(IpcEvents.device.firmwareUpgradeConnectPrompt, { message } as FirmwareUpgradeConnectPrompt); + } + + await waitForUhkDeviceConnected(device); + event.sender.send(IpcEvents.device.firmwareUpgradeConnectPrompt, null); + } + public async recoveryDevice(event: Electron.IpcMainEvent, args): Promise { const response: FirmwareUpgradeIpcResponse = { success: false, diff --git a/packages/uhk-common/src/models/firmware-upgrade-connect-prompt.ts b/packages/uhk-common/src/models/firmware-upgrade-connect-prompt.ts new file mode 100644 index 00000000000..e6a517763a8 --- /dev/null +++ b/packages/uhk-common/src/models/firmware-upgrade-connect-prompt.ts @@ -0,0 +1,3 @@ +export interface FirmwareUpgradeConnectPrompt { + message: string; +} diff --git a/packages/uhk-common/src/models/index.ts b/packages/uhk-common/src/models/index.ts index ad794f03f4d..4e52cc9aebc 100644 --- a/packages/uhk-common/src/models/index.ts +++ b/packages/uhk-common/src/models/index.ts @@ -10,6 +10,7 @@ export * from './device-version-information.js'; export * from './dongle.js'; export * from './firmware-json.js'; export * from './firmware-repo-info.js'; +export * from './firmware-upgrade-connect-prompt.js'; export * from './firmware-upgrade-method.js'; export * from './firmware-version.js'; export * from './firmware-version-info.js'; @@ -23,6 +24,7 @@ export * from './app-start-info.js'; export * from './configuration-reply.js'; export * from './version-information.js'; export * from './device-connection-state.js'; +export * from './module-firmware-upgrade-progress.js'; export * from './module-firmware-upgrade-skip-info.js'; export * from './module-slot-to-i2c-adress.js'; export * from './module-slot-id.js'; diff --git a/packages/uhk-common/src/models/module-firmware-upgrade-progress.ts b/packages/uhk-common/src/models/module-firmware-upgrade-progress.ts new file mode 100644 index 00000000000..78515af673c --- /dev/null +++ b/packages/uhk-common/src/models/module-firmware-upgrade-progress.ts @@ -0,0 +1,4 @@ +export interface ModuleFirmwareUpgradeProgress { + moduleName: string; + progress: number; +} diff --git a/packages/uhk-common/src/util/ipcEvents.ts b/packages/uhk-common/src/util/ipcEvents.ts index 61916764982..5bcfe44ba70 100644 --- a/packages/uhk-common/src/util/ipcEvents.ts +++ b/packages/uhk-common/src/util/ipcEvents.ts @@ -51,6 +51,8 @@ export class Device { public static readonly updateFirmware = 'device-update-firmware'; public static readonly updateFirmwareJson = 'device-update-firmware-json'; public static readonly updateFirmwareReply = 'device-update-firmware-reply'; + public static readonly firmwareUpgradeConnectPrompt = 'device-firmware-upgrade-connect-prompt'; + public static readonly moduleFirmwareUpgradeProgress = 'device-module-firmware-upgrade-progress'; public static readonly moduleFirmwareUpgradeSkip = 'device-module-firmware-upgrade-skip'; public static readonly moduleFirmwareUpgrading = 'device-module-firmware-upgrading'; public static readonly startConnectionPoller = 'device-start-connection-poller'; diff --git a/packages/uhk-usb/src/uhk-operations.ts b/packages/uhk-usb/src/uhk-operations.ts index ff41a8f074b..eb56a8766ad 100644 --- a/packages/uhk-usb/src/uhk-operations.ts +++ b/packages/uhk-usb/src/uhk-operations.ts @@ -123,22 +123,22 @@ export class UhkOperations { await this.device.write(transfer); } - public async updateDeviceFirmware(firmwarePath: string, device: UhkDeviceProduct): Promise { + public async updateDeviceFirmware(firmwarePath: string, device: UhkDeviceProduct, onProgress?: (percent: number) => void): Promise { this.logService.misc(`[UhkOperations] Start flashing device firmware with ${device.firmwareUpgradeMethod}`); switch (device.firmwareUpgradeMethod) { case FIRMWARE_UPGRADE_METHODS.KBOOT: - return this.updateRightFirmwareWithKboot(firmwarePath, device); + return this.updateRightFirmwareWithKboot(firmwarePath, device, onProgress); case FIRMWARE_UPGRADE_METHODS.MCUBOOT: - return this.updateFirmwareWithMcuManager(firmwarePath, device); + return this.updateFirmwareWithMcuManager(firmwarePath, device, onProgress); default: throw new Error(`Firmware upgrade method not implemented: ${device.firmwareUpgradeMethod}`); } } - public async updateRightFirmwareWithKboot(firmwarePath: string, device: UhkDeviceProduct): Promise { + public async updateRightFirmwareWithKboot(firmwarePath: string, device: UhkDeviceProduct, onProgress?: (percent: number) => void): Promise { if (!(await existsAsync(firmwarePath))) { throw new Error(`Firmware path not found: ${firmwarePath}`); } @@ -162,13 +162,20 @@ export class UhkOperations { this.logService.misc('[UhkOperations] Read RIGHT firmware from file'); const bootloaderMemoryMap = await readBootloaderFirmwareFromHexFileAsync(firmwarePath); this.logService.misc('[UhkOperations] Write memory'); + const totalBytes = [...bootloaderMemoryMap.values()].reduce((sum, data) => sum + data.length, 0); + let writtenBytes = 0; + onProgress?.(0); for (const [startAddress, data] of bootloaderMemoryMap.entries()) { const dataOption: DataOption = { startAddress, data }; - await kboot.writeMemory(dataOption); + const bytesBeforeEntry = writtenBytes; + await kboot.writeMemory(dataOption, entryPercent => { + onProgress?.(Math.min(100, Math.round((bytesBeforeEntry + data.length * entryPercent / 100) / totalBytes * 100))); + }); + writtenBytes += data.length; } this.logService.misc('[UhkOperations] Reset bootloader'); @@ -178,7 +185,7 @@ export class UhkOperations { this.logService.misc('[UhkOperations] Right firmware successfully flashed'); } - public async updateFirmwareWithMcuManager(firmwarePath: string, device: UhkDeviceProduct) { + public async updateFirmwareWithMcuManager(firmwarePath: string, device: UhkDeviceProduct, onProgress?: (percent: number) => void) { if (!(await existsAsync(firmwarePath))) { throw new Error(`Firmware path not found: ${firmwarePath}`); } @@ -199,7 +206,7 @@ export class UhkOperations { this.logService.misc(`[UhkOperations] Read ${device.logName} firmware from file`); const configData = fs.readFileSync(firmwarePath); this.logService.misc('[UhkOperations] Write memory with mcumgr'); - await mcuManager.imageUpload(configData); + await mcuManager.imageUpload(configData, onProgress); this.logService.misc('[UhkOperations] Reset mcu bootloader'); await mcuManager.reset(); this.logService.misc('[UhkOperations] Close mcu communication channels'); @@ -207,14 +214,15 @@ export class UhkOperations { this.logService.misc(`[UhkOperations] ${device.logName} firmware successfully flashed`); } - public async updateLeftModuleWithKboot(firmwarePath: string, device: UhkDeviceProduct): Promise { - return this.updateModuleWithKboot(firmwarePath, device, LEFT_HALF_MODULE); + public async updateLeftModuleWithKboot(firmwarePath: string, device: UhkDeviceProduct, onProgress?: (percent: number) => void): Promise { + return this.updateModuleWithKboot(firmwarePath, device, LEFT_HALF_MODULE, onProgress); } public async updateModuleWithKboot( firmwarePath: string, device: UhkDeviceProduct, - module: UhkModule + module: UhkModule, + onProgress?: (percent: number) => void ): Promise { this.logService.misc(`[UhkOperations] Start flashing "${module.name}" module firmware`); await this.device.reenumerate({ @@ -279,7 +287,7 @@ export class UhkOperations { this.logService.misc('[UhkOperations] Write memory'); await kboot.configureI2c(module.i2cAddress); - await kboot.writeMemory({ startAddress: 0, data: configData }); + await kboot.writeMemory({ startAddress: 0, data: configData }, onProgress); this.logService.misc(`[UhkOperations] Reset "${module.name}" keyboard`); await kboot.reset(); diff --git a/packages/uhk-web/src/app/components/device/firmware/device-firmware.component.html b/packages/uhk-web/src/app/components/device/firmware/device-firmware.component.html index 8b66db18a84..1e6169f4981 100644 --- a/packages/uhk-web/src/app/components/device/firmware/device-firmware.component.html +++ b/packages/uhk-web/src/app/components/device/firmware/device-firmware.component.html @@ -7,36 +7,57 @@

Firmware

-
    -
  • +
      +
    • - - - - - - {{ state.moduleName }} - firmware: - {{ state.gitRepo }}: - {{ state.currentFirmwareVersion }} - {{ gitTagText(state.currentFirmwareVersion, state.gitTag) }} - - {{ state.newFirmwareVersion }} {{ gitTagText(state.newFirmwareVersion, state.newGitTag) }} +
      + + + + - - is detected, and its firmware is implemented by the right keyboard half. + {{ state.moduleName }} + firmware: + {{ state.gitRepo }}: + {{ state.currentFirmwareVersion }} + {{ gitTagText(state.currentFirmwareVersion, state.gitTag) }} + + {{ state.newFirmwareVersion }} {{ gitTagText(state.newFirmwareVersion, state.newGitTag) }} + + + is detected, and its firmware is implemented by the right keyboard half. + {{ state.progressPercent || 0 }}% +
      +
      +
      +
    + +

    Firmware update doesn't work on Windows 7, Windows Vista, and Windows XP. Use Windows 10, Windows 8, Linux, or OSX instead.

    @@ -118,9 +139,13 @@

    role="alert"> {{ upgradeType }} update succeeded. + +

    + {{ showLog ? 'Hide' : 'Show' }} update log +

    -
    +
    + + + +
    + +
    @@ -80,7 +109,6 @@
    -
    • The Base layer is active by default (when no other layers are active). This layer can never be deleted.
    • diff --git a/packages/uhk-web/src/app/components/layers/layers.component.scss b/packages/uhk-web/src/app/components/layers/layers.component.scss index ced0d1aee56..138b1b24063 100644 --- a/packages/uhk-web/src/app/components/layers/layers.component.scss +++ b/packages/uhk-web/src/app/components/layers/layers.component.scss @@ -32,6 +32,10 @@ font-size: 18px; } + circle-tooltip { + align-self: center; + } + .btn-group { flex-wrap: wrap; .btn { @@ -39,5 +43,13 @@ } } } + + .layer-actions { + flex-wrap: wrap; + + .btn { + flex-grow: 0; + } + } } } diff --git a/packages/uhk-web/src/app/components/layers/layers.component.ts b/packages/uhk-web/src/app/components/layers/layers.component.ts index 9ce052116c1..89047a57ae9 100644 --- a/packages/uhk-web/src/app/components/layers/layers.component.ts +++ b/packages/uhk-web/src/app/components/layers/layers.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; -import { faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faCopy, faPaste, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { colord, RgbColor } from 'colord'; import { LayerName, RgbColorInterface } from 'uhk-common'; @@ -14,7 +14,9 @@ import { LayerOption, ModifyColorOfBacklightingColorPalettePayload } from '../.. changeDetection: ChangeDetectionStrategy.OnPush, }) export class LayersComponent { + @Input() allowLayerCopy = false; @Input() allowNewLayers: boolean; + @Input() canPasteLayer = false; @Input() current: LayerOption; @Input() layerOptions: LayerOption[]; @Input() paletteColors: Array = []; @@ -27,10 +29,14 @@ export class LayersComponent { @Output() selectLayer = new EventEmitter(); @Output() toggleColorFromPalette = new EventEmitter(); @Output() addLayer = new EventEmitter(); + @Output() copyLayer = new EventEmitter(); + @Output() pasteLayer = new EventEmitter(); @Output() removeLayer = new EventEmitter(); @ViewChild('deleteTooltip') deleteTooltip: NgbTooltip; + faCopy = faCopy; + faPaste = faPaste; faPlus = faPlus; faTrash = faTrash; LayerName = LayerName; @@ -56,6 +62,14 @@ export class LayersComponent { this.addLayer.emit(layerOption.id); } + onCopyLayer(): void { + this.copyLayer.emit(); + } + + onPasteLayer(): void { + this.pasteLayer.emit(); + } + onColorSelected(index): void { this.toggleColorFromPalette.emit(index); } diff --git a/packages/uhk-web/src/app/components/svg/wrap/svg-keyboard-wrap.component.html b/packages/uhk-web/src/app/components/svg/wrap/svg-keyboard-wrap.component.html index decace2d5ac..9424a9b138f 100644 --- a/packages/uhk-web/src/app/components/svg/wrap/svg-keyboard-wrap.component.html +++ b/packages/uhk-web/src/app/components/svg/wrap/svg-keyboard-wrap.component.html @@ -1,6 +1,8 @@ { this.keyPosition = this.keyElement.getBoundingClientRect(); diff --git a/packages/uhk-web/src/app/store/actions/keymap.ts b/packages/uhk-web/src/app/store/actions/keymap.ts index 5c599d4aaa6..05eeeb13177 100644 --- a/packages/uhk-web/src/app/store/actions/keymap.ts +++ b/packages/uhk-web/src/app/store/actions/keymap.ts @@ -9,6 +9,8 @@ import { ExchangeKeysActionModel, OpenPopoverModel } from '../../models'; export enum ActionTypes { Add = '[Keymap] Add keymap', AddLayer = '[Keymap] Add keymap layer', + CopyLayer = '[Keymap] Copy layer', + PasteLayer = '[Keymap] Paste layer', Duplicate = '[Keymap] Duplicate keymap', EditAbbr = '[Keymap] Edit keymap abbreviation', EditName = '[Keymap] Edit keymap title', @@ -41,6 +43,20 @@ export class AddLayerAction implements Action { } } +export class CopyLayerAction implements Action { + type = ActionTypes.CopyLayer; + + constructor(public payload: number) { + } +} + +export class PasteLayerAction implements Action { + type = ActionTypes.PasteLayer; + + constructor(public payload: number) { + } +} + export class DuplicateKeymapAction implements Action { type = ActionTypes.Duplicate; @@ -170,6 +186,8 @@ export class SelectLayerAction implements Action { export type Actions = AddKeymapAction | AddLayerAction + | CopyLayerAction + | PasteLayerAction | DuplicateKeymapAction | EditKeymapAbbreviationAction | EditKeymapNameAction 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 a41b0376706..71bec3d0f8c 100644 --- a/packages/uhk-web/src/app/store/effects/user-config.ts +++ b/packages/uhk-web/src/app/store/effects/user-config.ts @@ -111,6 +111,7 @@ export class UserConfigEffects { Keymaps.ActionTypes.EditAbbr, Keymaps.ActionTypes.SetDefault, Keymaps.ActionTypes.Remove, Keymaps.ActionTypes.SaveKey, Keymaps.ActionTypes.EditDescription, Keymaps.ActionTypes.ExchangeKeys, Keymaps.ActionTypes.AddLayer, Keymaps.ActionTypes.RemoveLayer, Keymaps.ActionTypes.SetKeyColor, + Keymaps.ActionTypes.PasteLayer, Macros.ActionTypes.Add, Macros.ActionTypes.Duplicate, Macros.ActionTypes.EditName, Macros.ActionTypes.Remove, Macros.ActionTypes.AddAction, Macros.ActionTypes.SaveAction, Macros.ActionTypes.DeleteAction, Macros.ActionTypes.ReorderAction, Macros.ActionTypes.DuplicateAction, diff --git a/packages/uhk-web/src/app/store/index.ts b/packages/uhk-web/src/app/store/index.ts index 445b53252c2..90308983996 100644 --- a/packages/uhk-web/src/app/store/index.ts +++ b/packages/uhk-web/src/app/store/index.ts @@ -139,6 +139,7 @@ export const getMacroMap = createSelector(userConfigState, fromUserConfig.getMac export const lastEditedKey = createSelector(userConfigState, fromUserConfig.lastEditedKey); export const getOpenPopover = createSelector(userConfigState, fromUserConfig.getOpenPopover); export const getSelectedLayerOption = createSelector(userConfigState, fromUserConfig.getSelectedLayerOption); +export const getHasCopiedLayer = createSelector(userConfigState, fromUserConfig.getHasCopiedLayer); export const getLayerOptions = createSelector(userConfigState, fromUserConfig.getLayerOptions); export const getSecondaryRoleOptions = createSelector(getSelectedLayerOption, getLayerOptions, (selectedLayer, layerOptions): SelectOptionData[] => { 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 da1cb72cab2..5d1381706a1 100644 --- a/packages/uhk-web/src/app/store/reducers/user-configuration.ts +++ b/packages/uhk-web/src/app/store/reducers/user-configuration.ts @@ -93,6 +93,8 @@ export interface State { theme: string; customAdvancedSecondaryRoleConfiguration?: AdvancedSecondaryRoleConfiguration; isCustomPresetTheLastLoadedPreset: boolean; + copiedLayer?: Layer; + copiedLayerMacroNames?: Map; } export const initialState: State = { @@ -382,6 +384,99 @@ export function reducer( return newState; } + case KeymapActions.ActionTypes.CopyLayer: { + const layerId = (action as KeymapActions.CopyLayerAction).payload; + const currentKeymap = state.userConfiguration.keymaps + .find(keymap => keymap.abbreviation === state.selectedKeymapAbbr); + const layerToCopy = currentKeymap?.layers.find(layer => layer.id === layerId); + + if (!layerToCopy) { + return state; + } + + // Snapshot the names of the macros referenced by the copied layer. Macro ids are + // config specific, so a later paste (possibly into another keymap or device) has to + // re-link them by name. + const copiedLayerMacroNames = new Map(); + for (const module of layerToCopy.modules) { + for (const keyAction of module.keyActions) { + if (keyAction instanceof PlayMacroAction && !copiedLayerMacroNames.has(keyAction.macroId)) { + const macro = state.userConfiguration.macros.find(m => m.id === keyAction.macroId); + if (macro) { + copiedLayerMacroNames.set(keyAction.macroId, macro.name); + } + } + } + } + + return { + ...state, + copiedLayer: new Layer(layerToCopy), + copiedLayerMacroNames, + }; + } + + case KeymapActions.ActionTypes.PasteLayer: { + if (!state.copiedLayer) { + return state; + } + + const targetLayerId = (action as KeymapActions.PasteLayerAction).payload; + const copiedLayer = state.copiedLayer; + const macroIdByName = new Map( + state.userConfiguration.macros.map(macro => [macro.name, macro.id]) + ); + const keymapAbbreviations = new Set(state.userConfiguration.keymaps.map(keymap => keymap.abbreviation)); + + const userConfiguration: UserConfiguration = Object.assign(new UserConfiguration(), state.userConfiguration); + userConfiguration.keymaps = userConfiguration.keymaps.map(keymap => { + if (keymap.abbreviation !== state.selectedKeymapAbbr) { + return keymap; + } + + keymap = new Keymap(keymap); + keymap.layers = keymap.layers.map(layer => { + if (layer.id !== targetLayerId) { + return layer; + } + + const pastedLayer = new Layer(); + pastedLayer.id = layer.id; + // Merge by module id + key count so pasting a layer that comes from a different + // keyboard or layout can never desync the target's module structure. Modules that + // have no compatible counterpart in the copied layer keep their current content. + pastedLayer.modules = layer.modules.map(targetModule => { + const sourceModule = copiedLayer.modules.find(module => + module.id === targetModule.id + && module.keyActions.length === targetModule.keyActions.length + ); + + if (!sourceModule) { + return new Module(targetModule); + } + + const pastedModule = new Module(targetModule); + pastedModule.keyActions = sourceModule.keyActions.map(keyAction => + remapCopiedKeyAction(keyAction, state.copiedLayerMacroNames, macroIdByName, keymapAbbreviations) + ); + + return pastedModule; + }); + + setSvgKeyboardCoverColorsOfLayer(userConfiguration.backlightingMode, pastedLayer, state.theme); + + return pastedLayer; + }); + + return keymap; + }); + + return { + ...state, + userConfiguration, + }; + } + case KeymapActions.ActionTypes.EditName: { const payload = (action as KeymapActions.EditKeymapNameAction).payload; @@ -1304,6 +1399,7 @@ export const getLayerOptions = (state: State): LayerOption[] => Array .from(state.layerOptions.values()) .sort((a, b) => a.order - b.order); export const getSelectedLayerOption = (state: State): LayerOption => state.selectedLayerOption; +export const getHasCopiedLayer = (state: State): boolean => !!state.copiedLayer; export const getSelectedMacroAction = (state: State): SelectedMacroAction => state.selectedMacroAction; export const getSelectedModuleConfiguration = (state: State): ModuleConfiguration => { if(!state.selectedModuleConfigurationId) { @@ -1550,6 +1646,53 @@ function assignUserConfiguration(state: State, userConfig: UserConfiguration, th } +/** + * Clones a key action coming from a copied layer and re-links or neutralizes its config specific + * references so that a paste can never introduce a dangling reference: + * - PlayMacroAction is re-linked to the target macro sharing the copied macro's name; if no such + * macro exists the key becomes a NoneAction (keeping its color). + * - SwitchKeymapAction is kept only if the referenced keymap abbreviation still exists, otherwise it + * becomes a NoneAction (keeping its color). + * SwitchLayerAction and keystroke secondary roles reference layers by fixed enum value, so they are + * safe to leave untouched even when the target keymap lacks that layer. + */ +function remapCopiedKeyAction( + keyAction: KeyAction, + macroNamesById: Map | undefined, + macroIdByName: Map, + keymapAbbreviations: Set, +): KeyAction { + const clonedKeyAction = KeyActionHelper.fromKeyAction(keyAction); + + if (clonedKeyAction instanceof PlayMacroAction) { + const macroName = macroNamesById?.get(clonedKeyAction.macroId); + const targetMacroId = macroName === undefined ? undefined : macroIdByName.get(macroName); + + if (targetMacroId === undefined) { + return toNoneActionWithColor(clonedKeyAction); + } + + clonedKeyAction.macroId = targetMacroId; + + return clonedKeyAction; + } + + if (clonedKeyAction instanceof SwitchKeymapAction && !keymapAbbreviations.has(clonedKeyAction.keymapAbbreviation)) { + return toNoneActionWithColor(clonedKeyAction); + } + + return clonedKeyAction; +} + +function toNoneActionWithColor(keyAction: KeyAction): NoneAction { + const noneAction = new NoneAction(); + noneAction.r = keyAction.r; + noneAction.g = keyAction.g; + noneAction.b = keyAction.b; + + return noneAction; +} + function saveKeyAction(userConfig: UserConfiguration, action: KeymapActions.SaveKeyAction): UserConfiguration { const payload = action.payload; const keyIndex: number = payload.key; diff --git a/packages/uhk-web/src/styles/_global.scss b/packages/uhk-web/src/styles/_global.scss index 59f7da8520c..a29e495b7f8 100644 --- a/packages/uhk-web/src/styles/_global.scss +++ b/packages/uhk-web/src/styles/_global.scss @@ -280,6 +280,11 @@ kbd { max-width: 350px; } +.tooltip-layer-action .tooltip-inner { + width: 320px; + max-width: 320px; +} + mwl-confirmation-popover-window { .popover { &.bottom {