From db7cbeecb853ecc319315edc09982c4c5bd58ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Sun, 12 Jul 2026 22:08:24 +0200 Subject: [PATCH 1/8] feat: auto-group macros in the sidebar Add optional sidebar grouping derived from macro names, with settings for camelCase splitting, nesting depth, and minimum group size. Grouping is off by default and only affects sidebar display, not stored macro names. Closes #1845 Co-authored-by: Cursor --- .../src/models/application-settings.ts | 5 + packages/uhk-common/src/models/index.ts | 1 + .../src/models/macro-grouping-settings.ts | 57 ++++++ .../src/util/group-macros-by-name.test.ts | 147 ++++++++++++++ .../src/util/group-macros-by-name.ts | 192 ++++++++++++++++++ packages/uhk-common/src/util/index.ts | 1 + .../agent/settings/settings.component.html | 63 +++++- .../agent/settings/settings.component.scss | 24 +++ .../agent/settings/settings.component.ts | 12 +- .../side-menu/side-menu.component.html | 39 +++- .../side-menu/side-menu.component.scss | 53 ++++- .../side-menu/side-menu.component.ts | 67 +++++- packages/uhk-web/src/app/models/index.ts | 1 + .../src/app/models/macro-menu-tree-node.ts | 4 + .../src/app/models/side-menu-page-state.ts | 2 + packages/uhk-web/src/app/store/actions/app.ts | 11 +- packages/uhk-web/src/app/store/effects/app.ts | 1 + packages/uhk-web/src/app/store/index.ts | 12 +- .../src/app/store/reducers/app.reducer.ts | 18 +- 19 files changed, 690 insertions(+), 20 deletions(-) create mode 100644 packages/uhk-common/src/models/macro-grouping-settings.ts create mode 100644 packages/uhk-common/src/util/group-macros-by-name.test.ts create mode 100644 packages/uhk-common/src/util/group-macros-by-name.ts create mode 100644 packages/uhk-web/src/app/models/macro-menu-tree-node.ts diff --git a/packages/uhk-common/src/models/application-settings.ts b/packages/uhk-common/src/models/application-settings.ts index 9f91a359c0d..ba50a375cdf 100644 --- a/packages/uhk-common/src/models/application-settings.ts +++ b/packages/uhk-common/src/models/application-settings.ts @@ -1,3 +1,4 @@ +import { MacroGroupingSettings } from './macro-grouping-settings.js'; import { RgbColorInterface } from './rgb-color-interface.js'; export enum AppTheme { @@ -34,4 +35,8 @@ export interface ApplicationSettings { * If true, the Advanced settings menu is shown on Agent startup. */ alwaysEnableAdvancedMode?: boolean; + /** + * Sidebar macro grouping preferences. + */ + macroGrouping?: Partial; } diff --git a/packages/uhk-common/src/models/index.ts b/packages/uhk-common/src/models/index.ts index d5863a2a207..dec9dcd6082 100644 --- a/packages/uhk-common/src/models/index.ts +++ b/packages/uhk-common/src/models/index.ts @@ -1,4 +1,5 @@ export * from './application-settings.js'; +export * from './macro-grouping-settings.js'; export * from './backup-user-configuration.js'; export * from './backup-user-configuration-info.js'; export * from './ble-address-pair.js'; diff --git a/packages/uhk-common/src/models/macro-grouping-settings.ts b/packages/uhk-common/src/models/macro-grouping-settings.ts new file mode 100644 index 00000000000..41caf7e899f --- /dev/null +++ b/packages/uhk-common/src/models/macro-grouping-settings.ts @@ -0,0 +1,57 @@ +export interface MacroGroupingSettings { + camelCaseSeparation: boolean; + enabled: boolean; + maxDepth: number; + minChildren: number; +} + +export const MACRO_GROUPING_MIN_DEPTH = 1; + +// Mirrors side-menu.component.scss ($side-menu-width, $macro-tree-base-indent, $macro-tree-indent-step). +const MACRO_TREE_SIDE_MENU_WIDTH_PX = 250; +const MACRO_TREE_LEVEL1_PADDING_PX = 24; +const MACRO_TREE_BASE_INDENT_PX = 32; +const MACRO_TREE_INDENT_STEP_PX = 12; +const MACRO_TREE_RESERVED_RIGHT_PX = 48; +const MACRO_TREE_MIN_LABEL_WIDTH_PX = 72; + +/** + * Deepest macro-tree depth the sidebar layout can fit before labels get too narrow. + * The CSS indent is unbounded; this limit comes from the 250px sidebar width. + */ +export const MACRO_GROUPING_MAX_DEPTH = Math.max( + MACRO_GROUPING_MIN_DEPTH, + Math.floor( + (MACRO_TREE_SIDE_MENU_WIDTH_PX + - MACRO_TREE_LEVEL1_PADDING_PX + - MACRO_TREE_BASE_INDENT_PX + - MACRO_TREE_RESERVED_RIGHT_PX + - MACRO_TREE_MIN_LABEL_WIDTH_PX) + / MACRO_TREE_INDENT_STEP_PX + ) +); + +export const DEFAULT_MACRO_GROUPING_SETTINGS: MacroGroupingSettings = { + camelCaseSeparation: false, + enabled: false, + maxDepth: 1, + minChildren: 2, +}; + +export function normalizeMacroGroupingSettings( + settings?: Partial +): MacroGroupingSettings { + const merged: MacroGroupingSettings = { + ...DEFAULT_MACRO_GROUPING_SETTINGS, + ...settings, + }; + + return { + ...merged, + maxDepth: Math.min( + MACRO_GROUPING_MAX_DEPTH, + Math.max(MACRO_GROUPING_MIN_DEPTH, merged.maxDepth) + ), + minChildren: Math.min(10, Math.max(2, merged.minChildren)), + }; +} diff --git a/packages/uhk-common/src/util/group-macros-by-name.test.ts b/packages/uhk-common/src/util/group-macros-by-name.test.ts new file mode 100644 index 00000000000..72a0e928e3a --- /dev/null +++ b/packages/uhk-common/src/util/group-macros-by-name.test.ts @@ -0,0 +1,147 @@ +import { describe, it } from 'node:test'; + +import { + DEFAULT_MACRO_GROUPING_SETTINGS, + MACRO_GROUPING_MAX_DEPTH, + normalizeMacroGroupingSettings, +} from '../models/macro-grouping-settings.js'; +import { groupMacrosByName, GroupableMacroItem, splitMacroName } from './group-macros-by-name.js'; + +function createMacro(id: number, name: string): GroupableMacroItem { + return { + id, + name + }; +} + +const ENABLED_MACRO_GROUPING_SETTINGS = { + ...DEFAULT_MACRO_GROUPING_SETTINGS, + enabled: true, +}; + +describe('splitMacroName', () => { + it('splits on non-alphanumeric separators', ({ assert }) => { + assert.deepEqual(splitMacroName('Doom: Chainsaw', false), ['Doom', 'Chainsaw']); + assert.deepEqual(splitMacroName('Doom_Plasma gun', false), ['Doom', 'Plasma', 'gun']); + }); + + it('optionally splits camel case segments', ({ assert }) => { + assert.deepEqual(splitMacroName('bindMouseMacros', true), ['bind', 'Mouse', 'Macros']); + }); + + it('does not split on dollar signs used in smart macro names', ({ assert }) => { + assert.deepEqual(splitMacroName('$onInit', true), ['$on', 'Init']); + assert.deepEqual(splitMacroName('$onInit', false), ['$onInit']); + }); +}); + +describe('groupMacrosByName', () => { + it('returns a flat list when grouping is disabled', ({ assert }) => { + const macros = [ + createMacro(1, 'Doom: Chainsaw'), + createMacro(2, 'Doom: Plasma gun') + ]; + + const result = groupMacrosByName(macros, { + ...DEFAULT_MACRO_GROUPING_SETTINGS, + enabled: false + }); + + assert.equal(result.length, 2); + assert.equal(result[0].type, 'macro'); + assert.equal(result[1].type, 'macro'); + }); + + it('groups macros that share a prefix separated by non-alphanumeric characters', ({ assert }) => { + const macros = [ + createMacro(1, 'Doom: Chainsaw'), + createMacro(2, 'Doom: Plasma gun'), + createMacro(3, 'Brightness up') + ]; + + const result = groupMacrosByName(macros, ENABLED_MACRO_GROUPING_SETTINGS); + + assert.equal(result.length, 2); + assert.deepEqual(result.map(node => node.type), ['macro', 'group']); + assert.equal(result[0].macro?.name, 'Brightness up'); + assert.equal(result[1].label, 'Doom'); + assert.equal(result[1].children?.length, 2); + assert.equal(result[1].children?.[0].displayName, 'Chainsaw'); + assert.equal(result[1].children?.[1].displayName, 'Plasma gun'); + }); + + it('does not create a group when there are fewer macros than minChildren', ({ assert }) => { + const macros = [ + createMacro(1, 'Doom: Chainsaw'), + createMacro(2, 'Brightness up') + ]; + + const result = groupMacrosByName(macros, ENABLED_MACRO_GROUPING_SETTINGS); + + assert.equal(result.length, 2); + assert.deepEqual(result.map(node => node.type), ['macro', 'macro']); + assert.equal(result[0].macro?.name, 'Brightness up'); + assert.equal(result[1].macro?.name, 'Doom: Chainsaw'); + }); + + it('groups by camel case when enabled', ({ assert }) => { + const macros = [ + createMacro(1, 'bindMouseLeft'), + createMacro(2, 'bindMouseRight'), + createMacro(3, 'brightnessUp') + ]; + + const result = groupMacrosByName(macros, { + ...ENABLED_MACRO_GROUPING_SETTINGS, + camelCaseSeparation: true + }); + + assert.equal(result.length, 2); + assert.deepEqual(result.map(node => node.type), ['group', 'macro']); + assert.equal(result[0].label, 'bind'); + assert.equal(result[0].children?.length, 2); + assert.deepEqual(result[0].children?.map(child => child.displayName), ['MouseLeft', 'MouseRight']); + assert.equal(result[1].macro?.name, 'brightnessUp'); + }); + + it('groups dollar-prefixed smart macro names under the dollar prefix', ({ assert }) => { + const macros = [ + createMacro(1, '$onInit'), + createMacro(2, '$onJoin'), + ]; + + const result = groupMacrosByName(macros, { + ...ENABLED_MACRO_GROUPING_SETTINGS, + camelCaseSeparation: true, + }); + + assert.equal(result.length, 1); + assert.equal(result[0].type, 'group'); + assert.equal(result[0].label, '$on'); + assert.deepEqual(result[0].children?.map(child => child.displayName), ['Init', 'Join']); + }); + + it('keeps parent-level remainders when deeper nesting does not apply', ({ assert }) => { + const macros = [ + createMacro(1, 'Open daily sites'), + createMacro(2, 'Open weekly sites'), + ]; + + const result = groupMacrosByName(macros, { + ...ENABLED_MACRO_GROUPING_SETTINGS, + maxDepth: 2, + }); + + assert.equal(result.length, 1); + assert.equal(result[0].label, 'Open'); + assert.deepEqual(result[0].children?.map(child => child.displayName), ['daily sites', 'weekly sites']); + }); +}); + +describe('normalizeMacroGroupingSettings', () => { + it('clamps maxDepth to the supported sidebar layout limit', ({ assert }) => { + const result = normalizeMacroGroupingSettings({ maxDepth: 99 }); + + assert.equal(result.maxDepth, MACRO_GROUPING_MAX_DEPTH); + }); +}); diff --git a/packages/uhk-common/src/util/group-macros-by-name.ts b/packages/uhk-common/src/util/group-macros-by-name.ts new file mode 100644 index 00000000000..c9c765aef38 --- /dev/null +++ b/packages/uhk-common/src/util/group-macros-by-name.ts @@ -0,0 +1,192 @@ +import { MacroGroupingSettings, normalizeMacroGroupingSettings } from '../models/macro-grouping-settings.js'; + +export interface GroupableMacroItem { + id: number; + name: string; +} + +export interface MacroMenuTreeNode { + children?: MacroMenuTreeNode[]; + displayName?: string; + label?: string; + macro?: TMacro; + path?: string; + type: 'group' | 'macro'; +} + +interface MacroGroupingItem { + groupingName: string; + macro: TMacro; +} + +export function groupMacrosByName( + macros: TMacro[], + settings: MacroGroupingSettings +): MacroMenuTreeNode[] { + const normalizedSettings = normalizeMacroGroupingSettings(settings); + + if (!normalizedSettings.enabled || macros.length === 0) { + return macros.map(macro => ({ type: 'macro', macro })); + } + + const items = macros.map(macro => ({ + groupingName: macro.name, + macro + })); + + return groupMacrosAtLevel(items, normalizedSettings, 0, ''); +} + +function groupMacrosAtLevel( + items: MacroGroupingItem[], + settings: MacroGroupingSettings, + depth: number, + parentPath: string +): MacroMenuTreeNode[] { + const sortedItems = [...items].sort((first, second) => first.groupingName.localeCompare(second.groupingName)); + + if (depth >= settings.maxDepth) { + return sortedItems.map(item => createMacroNode(item, depth)); + } + + const ungrouped: MacroMenuTreeNode[] = []; + const groupMap = new Map[]>(); + const pendingGroups: Array<{ + groupKey: string; + item: MacroGroupingItem; + remainder: string; + }> = []; + + for (const item of sortedItems) { + const segments = splitMacroName(item.groupingName, settings.camelCaseSeparation); + + if (segments.length <= 1) { + ungrouped.push(createMacroNode(item, depth)); + continue; + } + + pendingGroups.push({ + groupKey: segments[0], + item, + remainder: getRemainder(item.groupingName, segments[0]), + }); + } + + const groupCounts = new Map(); + + for (const pendingGroup of pendingGroups) { + groupCounts.set( + pendingGroup.groupKey, + (groupCounts.get(pendingGroup.groupKey) || 0) + 1 + ); + } + + for (const pendingGroup of pendingGroups) { + if ((groupCounts.get(pendingGroup.groupKey) || 0) < settings.minChildren) { + ungrouped.push(createMacroNode(pendingGroup.item, depth)); + continue; + } + + const groupedItems = groupMap.get(pendingGroup.groupKey) || []; + groupedItems.push({ + groupingName: pendingGroup.remainder, + macro: pendingGroup.item.macro + }); + groupMap.set(pendingGroup.groupKey, groupedItems); + } + + const groupedNodes = [...groupMap.entries()] + .sort(([firstKey], [secondKey]) => firstKey.localeCompare(secondKey)) + .map(([groupKey, groupedItems]) => { + const groupPath = parentPath ? `${parentPath}/${groupKey}` : groupKey; + + return { + type: 'group' as const, + children: groupMacrosAtLevel(groupedItems, settings, depth + 1, groupPath), + label: groupKey, + path: groupPath + }; + }); + + return sortMacroMenuTreeNodes([...ungrouped, ...groupedNodes]); +} + +function createMacroNode( + item: MacroGroupingItem, + depth: number +): MacroMenuTreeNode { + return { + type: 'macro', + displayName: depth > 0 ? item.groupingName : undefined, + macro: item.macro + }; +} + +function sortMacroMenuTreeNodes( + nodes: MacroMenuTreeNode[] +): MacroMenuTreeNode[] { + return [...nodes].sort((first, second) => getMacroMenuTreeNodeLabel(first).localeCompare(getMacroMenuTreeNodeLabel(second))); +} + +function getMacroMenuTreeNodeLabel(node: MacroMenuTreeNode): string { + if (node.type === 'group') { + return node.label || ''; + } + + return node.displayName || node.macro?.name || ''; +} + +export function splitMacroName(name: string, camelCaseSeparation: boolean): string[] { + let parts = name.split(/[^a-zA-Z0-9$]+/).filter(part => part.length > 0); + + if (camelCaseSeparation) { + parts = parts.flatMap(splitCamelCaseSegment); + } + + return parts; +} + +function splitCamelCaseSegment(segment: string): string[] { + const parts: string[] = []; + let current = ''; + + for (let index = 0; index < segment.length; index++) { + const character = segment[index]; + + if (index > 0 + && /[A-Z]/.test(character) + && current.length > 0 + && /[a-z0-9]$/.test(current)) { + parts.push(current); + current = character; + } else { + current += character; + } + } + + if (current.length > 0) { + parts.push(current); + } + + return parts.length > 0 ? parts : [segment]; +} + +function getRemainder(name: string, firstSegment: string): string { + const separatorMatch = name.match(new RegExp(`^${escapeRegExp(firstSegment)}[^a-zA-Z0-9$]+(.+)$`)); + + if (separatorMatch) { + return separatorMatch[1]; + } + + if (name.startsWith(firstSegment) && name.length > firstSegment.length) { + return name.slice(firstSegment.length); + } + + const segments = splitMacroName(name, false); + + return segments.slice(1).join(' '); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/uhk-common/src/util/index.ts b/packages/uhk-common/src/util/index.ts index 9917c00d7e2..66ba239c94f 100644 --- a/packages/uhk-common/src/util/index.ts +++ b/packages/uhk-common/src/util/index.ts @@ -10,6 +10,7 @@ export * from './find-uhk-module-by-id.js'; export * from './get-formatted-timestamp.js'; export * from './get-md5-hash-from-file-name.js'; export * from './get-slot-id-name.js'; +export * from './group-macros-by-name.js'; export * from './helpers.js'; export * from './is-bit-set.js'; export * from './is-device-protocol-support-firmware-checksum.js'; diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.html b/packages/uhk-web/src/app/components/agent/settings/settings.component.html index 8563b1c6338..32e2945b61a 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.html +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.html @@ -44,9 +44,8 @@

- -
-
+
+
+
+

Macro sidebar grouping

+
+
+ +
+ +
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+

The Follow operating system theme option may not be supported on all Linux distributions.

diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.scss b/packages/uhk-web/src/app/components/agent/settings/settings.component.scss index 9bc88c1d65a..b06cfcef6db 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.scss +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.scss @@ -1,3 +1,27 @@ +h1 { + margin-bottom: 1rem; +} + .theme-selector { min-width: 230px; } + +.macro-grouping-number-input { + width: 4rem; +} + +.macro-grouping-checkbox { + align-items: center; + display: flex; + gap: 0.375rem; +} + +.macro-grouping-inline-option { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.macro-grouping-dependent-options--disabled { + opacity: 0.55; +} diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.ts b/packages/uhk-web/src/app/components/agent/settings/settings.component.ts index 7087de85d47..797fe99fa54 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.ts +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.ts @@ -4,7 +4,7 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { faCog } from '@fortawesome/free-solid-svg-icons'; -import { AppTheme, AppThemeSelect } from 'uhk-common'; +import { AppTheme, AppThemeSelect, MACRO_GROUPING_MAX_DEPTH, MacroGroupingSettings } from 'uhk-common'; import { AppState, appUpdateSettingsState, @@ -12,6 +12,7 @@ import { getAnimationEnabled, getAppTheme, getIsAdvancedSettingsMenuVisible, + getMacroGroupingSettings, getOperatingSystem, getSupportedThemes, keyboardHalvesAlwaysJoined @@ -21,7 +22,7 @@ import { CheckForUpdateNowAction, ToggleCheckForUpdateOnStartupAction } from '../../../store/actions/auto-update-settings'; -import { OpenConfigFolderAction, SetAppThemeAction, ToggleAnimationEnabledAction, ToggleKeyboardHalvesAlwaysJoinedAction } from '../../../store/actions/app'; +import { OpenConfigFolderAction, SetAppThemeAction, SetMacroGroupingSettingsAction, ToggleAnimationEnabledAction, ToggleKeyboardHalvesAlwaysJoinedAction } from '../../../store/actions/app'; import { ToggleAlwaysEnableAdvancedModeAction } from '../../../store/actions/advance-settings.action'; import { OperatingSystem } from '../../../models/operating-system'; @@ -44,6 +45,8 @@ export class SettingsComponent { keyboardHalvesAlwaysJoined$: Observable; alwaysEnableAdvancedMode$: Observable; alwaysEnableAdvancedModeSettingVisible$: Observable; + macroGroupingSettings$: Observable; + macroGroupingMaxDepth = MACRO_GROUPING_MAX_DEPTH; constructor(private store: Store) { this.updateSettingsState$ = store.select(appUpdateSettingsState); @@ -54,6 +57,7 @@ export class SettingsComponent { this.keyboardHalvesAlwaysJoined$ = store.select(keyboardHalvesAlwaysJoined); this.alwaysEnableAdvancedMode$ = store.select(getAlwaysEnableAdvancedMode); this.alwaysEnableAdvancedModeSettingVisible$ = store.select(getIsAdvancedSettingsMenuVisible); + this.macroGroupingSettings$ = store.select(getMacroGroupingSettings); } openConfigFolder(): void { @@ -84,4 +88,8 @@ export class SettingsComponent { this.store.dispatch(new ToggleAlwaysEnableAdvancedModeAction(enabled)); } + updateMacroGroupingSettings(settings: Partial): void { + this.store.dispatch(new SetMacroGroupingSettingsAction(settings)); + } + } diff --git a/packages/uhk-web/src/app/components/side-menu/side-menu.component.html b/packages/uhk-web/src/app/components/side-menu/side-menu.component.html index d133669cb8b..d5a5ac3bc47 100644 --- a/packages/uhk-web/src/app/components/side-menu/side-menu.component.html +++ b/packages/uhk-web/src/app/components/side-menu/side-menu.component.html @@ -222,22 +222,41 @@ (click)="toggleMenuItem('macro')">
+ + + + + + + - +