From a8151b2b29a1bd9279447cbfc5e7091e215c9911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Sat, 8 Aug 2026 23:08:44 +0200 Subject: [PATCH 1/2] feat: improve scancode selector with tiled category layout Replace the scrolling ng-select scancode dropdown with a compact category panel that tiles short labels and keeps incremental search, addressing #2288. Co-authored-by: Cursor --- CHANGELOG.md | 1 + .../tab/keypress/keypress-tab.component.html | 34 +- .../tab/keypress/keypress-tab.component.ts | 43 +- .../tab/keypress/scancode-select/index.ts | 1 + .../scancode-select.component.html | 130 ++++++ .../scancode-select.component.scss | 166 ++++++++ .../scancode-select.component.ts | 381 ++++++++++++++++++ packages/uhk-web/src/app/shared.module.ts | 8 +- packages/uhk-web/src/styles/_global.scss | 5 + packages/uhk-web/src/styles/themes/_dark.scss | 2 + .../uhk-web/src/styles/themes/_light.scss | 4 + 11 files changed, 722 insertions(+), 53 deletions(-) create mode 100644 packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/index.ts create mode 100644 packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.html create mode 100644 packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.scss create mode 100644 packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 215a5e7b975..c00737eb612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Firmware: 18.0.0 [[release](https://github.com/UltimateHackingKeyboard/firmware/ - Make host connection management slot-focused instead of host-focused. - Group macros in the sidebar - Fix: refuse importing a user configuration newer than Agent supports, and refuse saving one newer than the firmware supports. +- Improve the scancode selector with a tiled category layout. ## [10.1.0] - 2026-06-23 diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html index 394783ce33d..018f3a473fe 100644 --- a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html +++ b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.html @@ -1,32 +1,14 @@
Scancode:
- - - - {{ item.text }} - - {{ item.additional?.explanation}} - - - - - - {{ addTagText(search) }} - - +

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

diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts index 809dd2dd5f3..529ec237e0a 100644 --- a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts +++ b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts @@ -7,11 +7,9 @@ import { OnChanges, Output, SimpleChanges, - ViewChild, inject, } from '@angular/core'; import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; -import { NgSelectComponent } from '@ng-select/ng-select'; import { copyRgbColor, KeyAction, KeystrokeAction, KeystrokeType, SCANCODES, SecondaryRoleAction } from 'uhk-common'; import { Tab } from '../tab'; @@ -20,14 +18,7 @@ import { SelectOptionData } from '../../../../models/select-option-data'; import { KeyModifierModel } from '../../../../models/key-modifier-model'; import { mapLeftRightModifierToKeyActionModifier } from '../../../../util'; import { RemapInfo } from '../../../../models/remap-info'; - -interface FlatOptions { - id: string; - text: string; - group?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - additional?: any; -} +import { ScancodeSelectOption } from './scancode-select'; interface SearchResult { isMatch: boolean; @@ -49,15 +40,14 @@ export class KeypressTabComponent extends Tab implements OnChanges { @Input() secondaryRoleOptions: SelectOptionData[]; @Output() keyActionChange = new EventEmitter(); - @ViewChild('scancodeSelect', { static: true }) scancodeSelect: NgSelectComponent; leftModifiers: KeyModifierModel[]; rightModifiers: KeyModifierModel[]; - scanCodeGroups: Array; - secondaryRoleGroups: Array = []; + scanCodeGroups: Array; + secondaryRoleGroups: Array = []; - selectedScancodeOption: FlatOptions; + selectedScancodeOption: ScancodeSelectOption; selectedSecondaryRoleIndex: number; warningVisible: boolean; faInfoCircle = faInfoCircle; @@ -123,7 +113,6 @@ export class KeypressTabComponent extends Tab implements OnChanges { this.leftModifiers = event.left; this.rightModifiers = event.right; this.keyActionChanged(); - this.scancodeSelect.writeValue(this.selectedScancodeOption.text || ''); } fromKeyAction(keyAction: KeyAction): boolean { @@ -189,7 +178,11 @@ export class KeypressTabComponent extends Tab implements OnChanges { this.keyActionChanged(); } - addTagFn (name: string): FlatOptions | boolean { + addTag = (name: string): ScancodeSelectOption | boolean => this.addTagFn(name); + + getAddTagText = (term: string): string => this.addTagText(term); + + addTagFn (name: string): ScancodeSelectOption | boolean { const mediaSearchResult = isMediaSearch(name); if (mediaSearchResult.isMatch) { const option = { @@ -268,7 +261,7 @@ export class KeypressTabComponent extends Tab implements OnChanges { return ''; } - searchFn(term: string, item: FlatOptions) { + searchFn = (term: string, item: ScancodeSelectOption): boolean => { term = term.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); if (new RegExp(term, 'i').test(item.text)) { return true; @@ -293,7 +286,7 @@ export class KeypressTabComponent extends Tab implements OnChanges { } return false; - } + }; modifiersTrackBy(index: number, modifier: KeyModifierModel): string { return `${modifier.value}${modifier.checked}`; @@ -306,12 +299,12 @@ export class KeypressTabComponent extends Tab implements OnChanges { this.cdRef.markForCheck(); } - private findScancodeOptionById(id: string): FlatOptions { + private findScancodeOptionById(id: string): ScancodeSelectOption { return this.scanCodeGroups.find(scancode => scancode.id === id) || - this.addTagFn(id) as FlatOptions; + this.addTagFn(id) as ScancodeSelectOption; } - private findScancodeOptionByScancode(scancode: number, type: KeystrokeType): FlatOptions { + private findScancodeOptionByScancode(scancode: number, type: KeystrokeType): ScancodeSelectOption { const typeToFind: string = (type === KeystrokeType.shortMedia || type === KeystrokeType.longMedia) ? 'media' : KeystrokeType[type]; const option = this.scanCodeGroups.find(x => x.additional.scancode === scancode && x.additional.type === typeToFind); @@ -322,20 +315,20 @@ export class KeypressTabComponent extends Tab implements OnChanges { switch (typeToFind) { case 'media': - return this.addTagFn(`M${scancode}`) as FlatOptions; + return this.addTagFn(`M${scancode}`) as ScancodeSelectOption; case 'basic': - return this.addTagFn(`B${scancode}`) as FlatOptions; + return this.addTagFn(`B${scancode}`) as ScancodeSelectOption; case 'system': - return this.addTagFn(`S${scancode}`) as FlatOptions; + return this.addTagFn(`S${scancode}`) as ScancodeSelectOption; default: break; } } - private toScancodeTypePair(option: FlatOptions): [number, string] { + private toScancodeTypePair(option: ScancodeSelectOption): [number, string] { if (!option) { return [0, 'basic']; } diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/index.ts b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/index.ts new file mode 100644 index 00000000000..a8908fe8163 --- /dev/null +++ b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/index.ts @@ -0,0 +1 @@ +export * from './scancode-select.component'; diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.html b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.html new file mode 100644 index 00000000000..6fc0f79d162 --- /dev/null +++ b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.html @@ -0,0 +1,130 @@ +
+
+
+
+ {{ displayText }} +
+
+ +
+
+ + +
+ + +
+
+ +
+ +
+
{{ group.name }}
+
+
+ +
+
+
+ +
+
+ + + +
+ No items found +
+
+
+
diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.scss b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.scss new file mode 100644 index 00000000000..55119565b76 --- /dev/null +++ b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.scss @@ -0,0 +1,166 @@ +:host { + display: block; + width: 100%; +} + +.scancode-select { + position: relative; +} + +.scancode-select__control { + display: flex; + align-items: center; + min-height: 1.75rem; + border: 1px solid var(--color-input-border); + border-radius: 0.2rem; + background-color: var(--color-input-bg, var(--color-popover-bg-light)); + cursor: pointer; +} + +.scancode-select--open .scancode-select__control { + border-color: var(--bs-primary, #0d6efd); +} + +.scancode-select__value-container { + position: relative; + flex: 1 1 auto; + min-width: 0; + padding: 0 0.5rem; +} + +.scancode-select__value { + font-size: 14px; + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-input-text, inherit); +} + +.scancode-select__arrow-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + flex: 0 0 auto; +} + +.scancode-select__arrow { + border-color: #999 transparent transparent; + border-style: solid; + border-width: 4px 4px 2.5px; +} + +.scancode-select--open .scancode-select__arrow { + border-width: 0 4px 4px; + border-color: transparent transparent #999; +} + +.scancode-select__panel { + display: flex; + flex-direction: column; + width: min(92vw, 20rem); + border: 1px solid var(--color-input-border); + border-radius: 0.2rem; + background-color: var(--color-input-bg, var(--color-popover-bg-light)); + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +.scancode-select__search { + flex: 0 0 auto; + padding: 0.35rem 0.5rem; + border-bottom: 1px solid var(--color-input-border); +} + +.scancode-select__input { + width: 100%; + border: 1px solid var(--color-input-border); + border-radius: 0.2rem; + outline: none; + background: var(--color-input-bg, var(--color-popover-bg-light)); + font-size: 14px; + line-height: 1.5; + padding: 0.15rem 0.4rem; + color: var(--color-input-text, inherit); +} + +.scancode-select__panel-scroll { + overflow: auto; + padding: 0.25rem 0.35rem 0.5rem; +} + +.scancode-select__ungrouped { + margin-bottom: 0.25rem; +} + +.scancode-select__group { + margin-bottom: 0.35rem; +} + +.scancode-select__group-header { + font-weight: 700; + padding: 0.15rem 0.35rem; + white-space: nowrap; + color: var(--color-input-text, inherit); +} + +.scancode-select__option { + display: block; + width: 100%; + border: 0; + background: transparent; + text-align: left; + padding: 1px 0.5rem; + font-size: 14px; + line-height: 1.4; + white-space: nowrap; + color: var(--color-input-text, inherit); + cursor: pointer; +} + +.scancode-select__tile-rows { + display: flex; + flex-direction: column; + gap: 1px; +} + +.scancode-select__tile-row { + display: grid; + gap: 1px; +} + +.scancode-select__tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(2.25rem, 1fr)); + gap: 1px; +} + +.scancode-select__group--tiled .scancode-select__option { + width: auto; + min-height: 1.5rem; + padding: 0.1rem 0.2rem; + text-align: center; + border-radius: 0.15rem; +} + +.scancode-select__option--marked, +.scancode-select__option:hover { + background-color: var(--color-select-hover-bg, var(--color-select-active-bg)); + color: var(--color-select-hover-text, var(--color-select-active-text)); +} + +.scancode-select__option--selected { + background-color: var(--color-select-active-bg); + color: var(--color-select-active-text); +} + +.scancode-select__tag { + margin-top: 0.25rem; + font-style: italic; +} + +.scancode-select__empty { + padding: 0.5rem; + color: var(--color-popover-label-disabled); +} diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts new file mode 100644 index 00000000000..3cc4c9f68f6 --- /dev/null +++ b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts @@ -0,0 +1,381 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostListener, + Input, + OnChanges, + Output, + SimpleChanges, + ViewChild, + inject, +} from '@angular/core'; +import { ConnectedPosition } from '@angular/cdk/overlay'; + +export interface ScancodeSelectOption { + id: string; + text: string; + group?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + additional?: any; +} + +export interface ScancodeSelectGroup { + name: string; + options: ScancodeSelectOption[]; + /** + * Explicit tile rows (Numpad). When empty, tiled groups use a wrapping grid + * over `options` instead. + */ + tileRows: ScancodeSelectOption[][]; + tiled: boolean; +} + +/** Categories with short labels that render as a compact tile grid. */ +const TILED_GROUPS = new Set([ + 'Function', + 'Letter', + 'Number', + 'Numpad', + 'Punctuation', + 'Whitespace', +]); + +/** Numpad display rows, in order. */ +const NUMPAD_TILE_ROWS = [ + ['0', '1', '2', '3', '4'], + ['5', '6', '7', '8', '9'], + ['/', '*', '-', '+', '.'], + ['Enter', 'NumLock'], +]; + +const MAX_HEIGHT_OFFSET = 20; +const SEARCH_BAR_HEIGHT = 44; + +@Component({ + selector: 'scancode-select', + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './scancode-select.component.html', + styleUrls: ['./scancode-select.component.scss'], +}) +export class ScancodeSelectComponent implements OnChanges { + @Input() items: ScancodeSelectOption[] = []; + @Input() selectedId = ''; + @Input() searchFn: ((term: string, item: ScancodeSelectOption) => boolean) | null = null; + @Input() addTag: ((term: string) => ScancodeSelectOption | boolean) | null = null; + @Input() addTagText: ((term: string) => string) | null = null; + + @Output() selectedIdChange = new EventEmitter(); + + @ViewChild('searchInput') searchInput?: ElementRef; + + isOpen = false; + inputValue = ''; + searchTerm = ''; + markedIndex = 0; + panelMaxHeight = 240; + ungroupedOptions: ScancodeSelectOption[] = []; + groups: ScancodeSelectGroup[] = []; + tagLabel = ''; + readonly overlayPositions: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 2, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -2, + }, + ]; + + private readonly cdRef = inject(ChangeDetectorRef); + private readonly host = inject(ElementRef); + + get selectedOption(): ScancodeSelectOption | undefined { + return this.items.find(item => item.id === this.selectedId); + } + + get displayText(): string { + return this.selectedOption?.text ?? ''; + } + + get flatOptions(): ScancodeSelectOption[] { + return [ + ...this.ungroupedOptions, + ...this.groups.flatMap(group => group.options), + ]; + } + + get showTag(): boolean { + return !!this.tagLabel; + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.items || changes.selectedId) { + this.rebuildFilteredView(); + } + } + + open(): void { + if (this.isOpen) { + return; + } + + this.isOpen = true; + // Show every category on open; filtering starts when the user types. + this.inputValue = this.displayText; + this.searchTerm = ''; + this.rebuildFilteredView(); + this.markSelectedOption(); + this.updatePanelMaxHeight(); + this.cdRef.markForCheck(); + + setTimeout(() => { + const input = this.searchInput?.nativeElement; + if (input) { + input.focus(); + input.select(); + } + }); + } + + close(): void { + if (!this.isOpen) { + return; + } + + this.isOpen = false; + this.inputValue = ''; + this.searchTerm = ''; + this.rebuildFilteredView(); + this.cdRef.markForCheck(); + } + + toggle(): void { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + + onSearchInput(value: string): void { + this.inputValue = value; + this.searchTerm = value; + this.markedIndex = 0; + this.rebuildFilteredView(); + this.cdRef.markForCheck(); + } + + onOptionMouseEnter(option: ScancodeSelectOption): void { + const index = this.flatOptions.findIndex(item => item.id === option.id); + if (index >= 0) { + this.markedIndex = index; + } + } + + onTagMouseEnter(): void { + this.markedIndex = this.flatOptions.length; + } + + isMarked(option: ScancodeSelectOption): boolean { + return this.flatOptions[this.markedIndex]?.id === option.id; + } + + isTagMarked(): boolean { + return this.showTag && this.markedIndex === this.flatOptions.length; + } + + selectOption(option: ScancodeSelectOption): void { + this.selectedIdChange.emit(option.id); + this.close(); + } + + selectTag(): void { + if (!this.addTag || !this.searchTerm) { + return; + } + + const result = this.addTag(this.searchTerm); + if (result && typeof result !== 'boolean') { + this.selectedIdChange.emit(result.id); + } + this.close(); + } + + onControlMouseDown(event: MouseEvent): void { + // Keep focus handling in this component; avoid input blur before toggle. + event.preventDefault(); + this.toggle(); + if (this.isOpen) { + setTimeout(() => { + this.searchInput?.nativeElement.focus(); + this.searchInput?.nativeElement.select(); + }); + } + } + + @HostListener('document:keydown', ['$event']) + onDocumentKeyDown(event: KeyboardEvent): void { + if (!this.isOpen || event.key !== 'Escape') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + this.close(); + } + + onKeyDown(event: KeyboardEvent): void { + if (!this.isOpen) { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.open(); + } + return; + } + + const navigableCount = this.flatOptions.length + (this.showTag ? 1 : 0); + + switch (event.key) { + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.close(); + break; + case 'ArrowDown': + event.preventDefault(); + if (navigableCount > 0) { + this.markedIndex = (this.markedIndex + 1) % navigableCount; + this.scrollMarkedOptionIntoView(); + } + break; + case 'ArrowUp': + event.preventDefault(); + if (navigableCount > 0) { + this.markedIndex = (this.markedIndex - 1 + navigableCount) % navigableCount; + this.scrollMarkedOptionIntoView(); + } + break; + case 'Enter': + event.preventDefault(); + if (this.isTagMarked()) { + this.selectTag(); + } else if (this.flatOptions[this.markedIndex]) { + this.selectOption(this.flatOptions[this.markedIndex]); + } + break; + case 'Tab': + this.close(); + break; + default: + break; + } + + this.cdRef.markForCheck(); + } + + private rebuildFilteredView(): void { + const term = this.searchTerm.trim(); + const ungroupedOptions: ScancodeSelectOption[] = []; + const groups: ScancodeSelectGroup[] = []; + const groupIndex = new Map(); + + for (const item of this.items) { + if (term && !this.matches(term, item)) { + continue; + } + + if (!item.group) { + ungroupedOptions.push(item); + continue; + } + + let index = groupIndex.get(item.group); + if (index === undefined) { + index = groups.length; + groupIndex.set(item.group, index); + groups.push({ + name: item.group, + options: [], + tileRows: [], + tiled: TILED_GROUPS.has(item.group), + }); + } + groups[index].options.push(item); + } + + for (const group of groups) { + if (group.name === 'Numpad') { + this.applyNumpadTileRows(group); + } + } + + this.ungroupedOptions = ungroupedOptions; + this.groups = groups; + this.tagLabel = term && this.addTagText ? this.addTagText(term) : ''; + + const navigableCount = this.flatOptions.length + (this.showTag ? 1 : 0); + if (this.markedIndex >= navigableCount) { + this.markedIndex = Math.max(0, navigableCount - 1); + } + } + + private applyNumpadTileRows(group: ScancodeSelectGroup): void { + const byText = new Map(group.options.map(option => [option.text, option])); + const tileRows: ScancodeSelectOption[][] = []; + + for (const rowTexts of NUMPAD_TILE_ROWS) { + const row = rowTexts + .map(text => byText.get(text)) + .filter((option): option is ScancodeSelectOption => !!option); + if (row.length > 0) { + tileRows.push(row); + } + } + + group.tileRows = tileRows; + // Keep keyboard navigation order aligned with the visual layout. + if (tileRows.length > 0) { + group.options = tileRows.flat(); + } + } + + private matches(term: string, item: ScancodeSelectOption): boolean { + if (this.searchFn) { + return this.searchFn(term, item); + } + + return item.text.toLowerCase().includes(term.toLowerCase()); + } + + private markSelectedOption(): void { + const selectedIndex = this.flatOptions.findIndex(item => item.id === this.selectedId); + this.markedIndex = selectedIndex >= 0 ? selectedIndex : 0; + } + + private updatePanelMaxHeight(): void { + const triggerRect = this.host.nativeElement.getBoundingClientRect(); + const spaceBelow = window.document.body.clientHeight - triggerRect.bottom - MAX_HEIGHT_OFFSET; + const spaceAbove = triggerRect.top - MAX_HEIGHT_OFFSET; + // Prefer the side with more room so a tall panel is less likely to be + // pushed back over the trigger (especially in the macro editor). + this.panelMaxHeight = Math.max(120, Math.max(spaceBelow, spaceAbove) - SEARCH_BAR_HEIGHT); + } + + private scrollMarkedOptionIntoView(): void { + setTimeout(() => { + document.querySelector('.scancode-select__option--marked') + ?.scrollIntoView({ block: 'nearest' }); + }); + } +} diff --git a/packages/uhk-web/src/app/shared.module.ts b/packages/uhk-web/src/app/shared.module.ts index e2c7d547446..3dbfabc9255 100644 --- a/packages/uhk-web/src/app/shared.module.ts +++ b/packages/uhk-web/src/app/shared.module.ts @@ -1,8 +1,9 @@ -import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { OverlayModule } from '@angular/cdk/overlay'; import { CommonModule } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { HttpClientModule } from '@angular/common/http'; import { ConfirmationPopoverModule } from 'angular-confirmation-popover'; import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { NotifierModule } from 'gramli-angular-notifier'; @@ -71,6 +72,7 @@ import { MouseTabComponent, NoneTabComponent, } from './components/popover/tab'; +import { ScancodeSelectComponent } from './components/popover/tab/keypress/scancode-select'; import { CaptureKeystrokeButtonComponent } from './components/popover/widgets/capture-keystroke'; import { IconComponent } from './components/popover/widgets/icon'; import { AboutComponent, SettingsComponent, ContributorBadgeComponent } from './components/agent'; @@ -227,6 +229,7 @@ import appInitFactory from './services/app-init-factory'; DeviceTabComponent, KeypressTabComponent, KeymapTabComponent, + ScancodeSelectComponent, LayerTabComponent, MacroTabComponent, MouseTabComponent, @@ -305,6 +308,7 @@ import appInitFactory from './services/app-init-factory'; MonacoEditorModule, NgSelectModule, NouisliderModule, + OverlayModule, NotifierModule.withConfig(angularNotifierConfig), ConfirmationPopoverModule.forRoot({ confirmButtonType: 'danger' // set defaults here diff --git a/packages/uhk-web/src/styles/_global.scss b/packages/uhk-web/src/styles/_global.scss index 355b6fe51a8..62d182fa84b 100644 --- a/packages/uhk-web/src/styles/_global.scss +++ b/packages/uhk-web/src/styles/_global.scss @@ -15,6 +15,11 @@ body { --tooltip-max-width: 200px; } +/* Above key-action popover (1051) so dropdowns like scancode-select are visible. */ +.cdk-overlay-container { + z-index: 1100; +} + .full-screen-component { display: block; height: 100%; diff --git a/packages/uhk-web/src/styles/themes/_dark.scss b/packages/uhk-web/src/styles/themes/_dark.scss index 543233eb44f..1124a973dd9 100644 --- a/packages/uhk-web/src/styles/themes/_dark.scss +++ b/packages/uhk-web/src/styles/themes/_dark.scss @@ -126,6 +126,8 @@ $input-focus-border-color: tint-color($component-active-bg, 25%) !default; --color-select-active-bg: #{$color-select-active-bg}; --color-select-active-text: #{$color-select-active-text}; + --color-select-hover-bg: #{$color-select-hover-bg}; + --color-select-hover-text: #{$color-select-hover-text}; --color-configuration-history-text: #{$color-text}; diff --git a/packages/uhk-web/src/styles/themes/_light.scss b/packages/uhk-web/src/styles/themes/_light.scss index 287671d09a9..c1f3bb60dff 100644 --- a/packages/uhk-web/src/styles/themes/_light.scss +++ b/packages/uhk-web/src/styles/themes/_light.scss @@ -66,6 +66,8 @@ $input-focus-border-color: lighten($component-active-bg, 25%) !default; --color-keyboard-key-active: #{$primary}; --color-module-puzzle-path-fill: #{$white}; + --color-input-bg: #{$input-bg}; + --color-input-text: #{$input-color}; --color-input-border: #ccc; --color-btn-secondary-border: #ccc; @@ -99,6 +101,8 @@ $input-focus-border-color: lighten($component-active-bg, 25%) !default; --color-select-active-bg: #{$color-select-active-bg}; --color-select-active-text: #{$color-select-active-text}; + --color-select-hover-bg: #{$color-select-hover-bg}; + --color-select-hover-text: #{$color-select-hover-text}; --color-configuration-history-text: #{$color-text}; From 2df358715105313ce6b850b0ba90bab0aa29a3af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Sat, 8 Aug 2026 23:16:10 +0200 Subject: [PATCH 2/2] fix: allow adding B/M/S scancode tags when a named key already uses that code Custom entries like B5 were hidden because letter B already maps to basic scancode 5. Offer the tag whenever that exact id is new, and prefer it on Enter. Co-authored-by: Cursor --- .../tab/keypress/keypress-tab.component.ts | 32 +++++++++++-------- .../scancode-select.component.ts | 15 ++++++--- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts index 529ec237e0a..3e4abb65fde 100644 --- a/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts +++ b/packages/uhk-web/src/app/components/popover/tab/keypress/keypress-tab.component.ts @@ -235,26 +235,32 @@ export class KeypressTabComponent extends Tab implements OnChanges { } addTagText(term: string): string { - const mediaSearchResult = isMediaSearch(term); + const normalizedTerm = term.trim(); + if (!normalizedTerm) { + return ''; + } - if (mediaSearchResult.isMatch && - !this.scanCodeGroups - .some(x => x.additional?.type === 'media' && x.additional?.scancode === mediaSearchResult.scancode)) { - return `Media scancode: ${mediaSearchResult.scancode}`; + // Allow M/B/S tags whenever that exact id is new. A named key may already + // use the same scancode (e.g. letter "B" is basic scancode 5), but users + // still need to be able to add "B5" as an explicit custom entry. + const idExists = this.scanCodeGroups + .some(option => option.id.toLowerCase() === normalizedTerm.toLowerCase()); + if (idExists) { + return ''; } - const basicSearchResult = isBasicSearch(term); + const mediaSearchResult = isMediaSearch(normalizedTerm); + if (mediaSearchResult.isMatch) { + return `Media scancode: ${mediaSearchResult.scancode}`; + } - if (basicSearchResult.isMatch && - !this.scanCodeGroups - .some(x => x.additional?.type === 'basic' && x.additional?.scancode === basicSearchResult.scancode)) { + const basicSearchResult = isBasicSearch(normalizedTerm); + if (basicSearchResult.isMatch) { return `Basic scancode: ${basicSearchResult.scancode}`; } - const systemSearchResult = isSystemSearch(term); - if (systemSearchResult.isMatch && - !this.scanCodeGroups - .some(x => x.additional?.type === 'system' && x.additional?.scancode === systemSearchResult.scancode)) { + const systemSearchResult = isSystemSearch(normalizedTerm); + if (systemSearchResult.isMatch) { return `System scancode: ${systemSearchResult.scancode}`; } diff --git a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts index 3cc4c9f68f6..f28aa6f36ff 100644 --- a/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts +++ b/packages/uhk-web/src/app/components/popover/tab/keypress/scancode-select/scancode-select.component.ts @@ -171,8 +171,10 @@ export class ScancodeSelectComponent implements OnChanges { onSearchInput(value: string): void { this.inputValue = value; this.searchTerm = value; - this.markedIndex = 0; this.rebuildFilteredView(); + // Prefer the custom M/B/S tag on Enter when the term is an exact custom + // scancode pattern (e.g. B5), even if a named key shares that scancode. + this.markedIndex = this.showTag ? this.flatOptions.length : 0; this.cdRef.markForCheck(); } @@ -201,11 +203,12 @@ export class ScancodeSelectComponent implements OnChanges { } selectTag(): void { - if (!this.addTag || !this.searchTerm) { + const term = this.searchTerm.trim(); + if (!this.addTag || !term) { return; } - const result = this.addTag(this.searchTerm); + const result = this.addTag(term); if (result && typeof result !== 'boolean') { this.selectedIdChange.emit(result.id); } @@ -325,8 +328,10 @@ export class ScancodeSelectComponent implements OnChanges { this.tagLabel = term && this.addTagText ? this.addTagText(term) : ''; const navigableCount = this.flatOptions.length + (this.showTag ? 1 : 0); - if (this.markedIndex >= navigableCount) { - this.markedIndex = Math.max(0, navigableCount - 1); + if (navigableCount === 0) { + this.markedIndex = 0; + } else if (this.markedIndex >= navigableCount) { + this.markedIndex = navigableCount - 1; } }