Skip to content

Commit 68114aa

Browse files
committed
🔧 Emit selection events for submenus
1 parent f802236 commit 68114aa

File tree

13 files changed

+218
-133
lines changed

13 files changed

+218
-133
lines changed

src/common/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,13 @@ export type KeyStroke = {
183183
*/
184184
export type KeySequence = Array<KeyStroke>;
185185

186+
/** Enum for the different item categories which can be hovered or selected. */
187+
export enum InteractionTarget {
188+
eItem = 'item',
189+
eSubmenu = 'submenu',
190+
eParent = 'parent',
191+
}
192+
186193
/**
187194
* There are different reasons why a menu should be shown. This type is used to describe
188195
* the request to show a menu. A menu can be shown because a shortcut was pressed (in this

src/common/ipc/ipc-observer-client.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111
import { EventEmitter } from 'events';
1212

1313
import * as IPCTypes from './types';
14-
import { TypedEventEmitter } from '..';
14+
import { TypedEventEmitter, InteractionTarget } from '..';
1515
import { createCrossWebSocket } from './cross-websocket';
1616

1717
/** These events are emitted by the IPC client when menu interactions occur. */
1818
type IPCObserverClientEvents = {
1919
open: [];
2020
cancel: [];
21-
select: [path: number[]];
22-
hover: [path: number[]];
21+
select: [target: InteractionTarget, path: number[]];
22+
hover: [target: InteractionTarget, path: number[]];
2323
error: [error: IPCTypes.IPCErrorReason];
2424
};
2525

@@ -98,9 +98,11 @@ export class IPCObserverClient extends (EventEmitter as new () => TypedEventEmit
9898
} else if (IPCTypes.CANCEL_MENU_MESSAGE.safeParse(msg).success) {
9999
this.emit('cancel');
100100
} else if (IPCTypes.SELECT_ITEM_MESSAGE.safeParse(msg).success) {
101-
this.emit('select', (msg as IPCTypes.SelectItemMessage).path);
101+
const { target, path } = msg as IPCTypes.SelectItemMessage;
102+
this.emit('select', target, path);
102103
} else if (IPCTypes.HOVER_ITEM_MESSAGE.safeParse(msg).success) {
103-
this.emit('hover', (msg as IPCTypes.HoverItemMessage).path);
104+
const { target, path } = msg as IPCTypes.HoverItemMessage;
105+
this.emit('hover', target, path);
104106
} else if (IPCTypes.ERROR_MESSAGE.safeParse(msg).success) {
105107
const errorMsg = msg as IPCTypes.ErrorMessage;
106108
console.error(`IPC Error (${errorMsg.reason}): ${errorMsg.description}`);

src/common/ipc/ipc-server.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import fs from 'fs';
1515
import path from 'path';
1616
import * as IPCTypes from './types';
1717

18-
import { TypedEventEmitter, MenuItem } from '..';
18+
import { TypedEventEmitter, MenuItem, InteractionTarget } from '..';
1919

2020
/**
2121
* Callbacks that are provided to the IPC server event handlers to report menu
@@ -25,9 +25,9 @@ import { TypedEventEmitter, MenuItem } from '..';
2525
*/
2626
export type IPCCallbacks = {
2727
onOpen: () => void;
28-
onSelect: (path: number[]) => void;
29-
onHover: (path: number[]) => void;
3028
onCancel: () => void;
29+
onSelect: (target: InteractionTarget, path: number[]) => void;
30+
onHover: (target: InteractionTarget, path: number[]) => void;
3131
};
3232

3333
/** These events are emitted by the IPC server when clients send requests. */
@@ -180,12 +180,20 @@ export class IPCServer extends (EventEmitter as new () => TypedEventEmitter<IPCS
180180
const openMsg: IPCTypes.OpenMenuMessage = { type: 'open-menu' };
181181
ws.send(JSON.stringify(openMsg));
182182
},
183-
onHover: (path: number[]) => {
184-
const hoverMsg: IPCTypes.HoverItemMessage = { type: 'hover-item', path };
183+
onHover: (target: InteractionTarget, path: number[]) => {
184+
const hoverMsg: IPCTypes.HoverItemMessage = {
185+
type: 'hover-item',
186+
target,
187+
path,
188+
};
185189
ws.send(JSON.stringify(hoverMsg));
186190
},
187-
onSelect: (path: number[]) => {
188-
const selectMsg: IPCTypes.SelectItemMessage = { type: 'select-item', path };
191+
onSelect: (target: InteractionTarget, path: number[]) => {
192+
const selectMsg: IPCTypes.SelectItemMessage = {
193+
type: 'select-item',
194+
target,
195+
path,
196+
};
189197
ws.send(JSON.stringify(selectMsg));
190198

191199
if (oneTime) {

src/common/ipc/ipc-show-menu-client.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111
import { EventEmitter } from 'events';
1212

1313
import * as IPCTypes from './types';
14-
import { TypedEventEmitter, MenuItem } from '..';
14+
import { TypedEventEmitter, MenuItem, InteractionTarget } from '..';
1515
import { createCrossWebSocket } from './cross-websocket';
1616

1717
/** These events are emitted by the IPC client when menu interactions occur. */
1818
type IPCShowMenuClientEvents = {
1919
cancel: [];
20-
select: [path: number[]];
21-
hover: [path: number[]];
20+
select: [target: InteractionTarget, path: number[]];
21+
hover: [target: InteractionTarget, path: number[]];
2222
error: [error: IPCTypes.IPCErrorReason];
2323
};
2424

@@ -34,9 +34,9 @@ type IPCShowMenuClientEvents = {
3434
* const client = new IPCShowMenuClient(12345, 1);
3535
* await client.init();
3636
* client.showMenu(menuItem);
37-
* client.on('select', (path) => { ... });
37+
* client.on('hover', (target, path) => { ... });
38+
* client.on('select', (target, path) => { ... });
3839
* client.on('cancel', () => { ... });
39-
* client.on('hover', (path) => { ... });
4040
*/
4141
export class IPCShowMenuClient extends (EventEmitter as new () => TypedEventEmitter<IPCShowMenuClientEvents>) {
4242
private ws: ReturnType<typeof createCrossWebSocket> | null = null;
@@ -90,11 +90,13 @@ export class IPCShowMenuClient extends (EventEmitter as new () => TypedEventEmit
9090
const msg = JSON.parse(data);
9191

9292
if (IPCTypes.SELECT_ITEM_MESSAGE.safeParse(msg).success) {
93-
this.emit('select', (msg as IPCTypes.SelectItemMessage).path);
93+
const { target, path } = msg as IPCTypes.SelectItemMessage;
94+
this.emit('select', target, path);
9495
} else if (IPCTypes.CANCEL_MENU_MESSAGE.safeParse(msg).success) {
9596
this.emit('cancel');
9697
} else if (IPCTypes.HOVER_ITEM_MESSAGE.safeParse(msg).success) {
97-
this.emit('hover', (msg as IPCTypes.HoverItemMessage).path);
98+
const { target, path } = msg as IPCTypes.HoverItemMessage;
99+
this.emit('hover', target, path);
98100
} else if (IPCTypes.ERROR_MESSAGE.safeParse(msg).success) {
99101
const errorMsg = msg as IPCTypes.ErrorMessage;
100102
console.error(`IPC Error (${errorMsg.reason}): ${errorMsg.description}`);

src/common/ipc/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import * as z from 'zod';
1212
import { MENU_ITEM_SCHEMA } from '../settings-schemata';
13+
import { InteractionTarget } from '..';
1314

1415
/** Enum of all possible reasons for declining a request. */
1516
export enum IPCErrorReason {
@@ -78,6 +79,7 @@ export const CANCEL_MENU_MESSAGE = z.object({
7879
*/
7980
export const SELECT_ITEM_MESSAGE = z.object({
8081
type: z.literal('select-item'),
82+
target: z.enum(InteractionTarget),
8183
path: z.array(z.number()),
8284
});
8385

@@ -87,6 +89,7 @@ export const SELECT_ITEM_MESSAGE = z.object({
8789
*/
8890
export const HOVER_ITEM_MESSAGE = z.object({
8991
type: z.literal('hover-item'),
92+
target: z.enum(InteractionTarget),
9093
path: z.array(z.number()),
9194
});
9295

src/main/app.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -492,14 +492,14 @@ export class KandoApp {
492492
*/
493493
private async createMenuWindow() {
494494
this.menuWindow = new MenuWindow(this, {
495-
onSelect: (path) => {
495+
onSelect: (target, path) => {
496496
for (const observer of this.ipcObservers.values()) {
497-
observer.onSelect(path);
497+
observer.onSelect(target, path);
498498
}
499499
},
500-
onHover: (path) => {
500+
onHover: (target, path) => {
501501
for (const observer of this.ipcObservers.values()) {
502-
observer.onHover(path);
502+
observer.onHover(target, path);
503503
}
504504
},
505505
onCancel: () => {

src/main/menu-window.ts

Lines changed: 98 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ import os from 'node:os';
1212
import { BrowserWindow, screen, ipcMain, app } from 'electron';
1313

1414
import { DeepReadonly } from './settings';
15-
import { ShowMenuRequest, Menu, MenuItem, WMInfo, SelectionSource } from '../common';
15+
import {
16+
ShowMenuRequest,
17+
Menu,
18+
MenuItem,
19+
WMInfo,
20+
SelectionSource,
21+
InteractionTarget,
22+
} from '../common';
1623
import { IPCCallbacks } from '../common/ipc';
1724
import { ItemActionRegistry } from './item-actions/item-action-registry';
1825
import { Notification } from './utils/notification';
@@ -663,106 +670,115 @@ export class MenuWindow extends BrowserWindow {
663670
// execute the action.
664671
ipcMain.on(
665672
'menu-window.select-item',
666-
(event, path: string, time: number, source: SelectionSource) => {
667-
const execute = (item: DeepReadonly<MenuItem>) => {
668-
ItemActionRegistry.getInstance()
669-
.execute(item, this.kando)
670-
.catch((error) => {
671-
Notification.show({
672-
title: 'Failed to execute action',
673-
message: error instanceof Error ? error.message : error,
674-
type: 'error',
673+
(
674+
event,
675+
target: InteractionTarget,
676+
path: string,
677+
time: number,
678+
source: SelectionSource
679+
) => {
680+
const pathArray = this.pathToArray(path);
681+
682+
if (target === InteractionTarget.eItem) {
683+
const execute = (item: DeepReadonly<MenuItem>) => {
684+
ItemActionRegistry.getInstance()
685+
.execute(item, this.kando)
686+
.catch((error) => {
687+
Notification.show({
688+
title: 'Failed to execute action',
689+
message: error instanceof Error ? error.message : error,
690+
type: 'error',
691+
});
675692
});
693+
};
694+
695+
let item: DeepReadonly<MenuItem>;
696+
let executeDelayed = false;
697+
698+
try {
699+
// Find the selected item.
700+
item = this.getMenuItemAtPath(this.lastMenu.root, path);
701+
702+
// If the action is not delayed, we execute it immediately.
703+
executeDelayed = ItemActionRegistry.getInstance().delayedExecution(item);
704+
if (!executeDelayed) {
705+
execute(item);
706+
}
707+
} catch (error) {
708+
Notification.show({
709+
title: 'Failed to select item',
710+
message: error instanceof Error ? error.message : error,
711+
type: 'error',
676712
});
677-
};
678-
679-
let item: DeepReadonly<MenuItem>;
680-
let executeDelayed = false;
681-
682-
try {
683-
// Find the selected item.
684-
item = this.getMenuItemAtPath(this.lastMenu.root, path);
685-
686-
// If the action is not delayed, we execute it immediately.
687-
executeDelayed = ItemActionRegistry.getInstance().delayedExecution(item);
688-
if (!executeDelayed) {
689-
execute(item);
690713
}
691-
} catch (error) {
692-
Notification.show({
693-
title: 'Failed to select item',
694-
message: error instanceof Error ? error.message : error,
695-
type: 'error',
714+
715+
// Also wait with the execution of the selected action until the fade-out
716+
// animation is finished to make sure that any resulting events (such as virtual
717+
// key presses) are not captured by the window.
718+
this.hideWindow().then(() => {
719+
// If the action is delayed, we execute it after the window is hidden.
720+
if (executeDelayed) {
721+
execute(item);
722+
}
696723
});
697-
}
698724

699-
// Also wait with the execution of the selected action until the fade-out
700-
// animation is finished to make sure that any resulting events (such as virtual
701-
// key presses) are not captured by the window.
702-
this.hideWindow().then(() => {
703-
// If the action is delayed, we execute it after the window is hidden.
704-
if (executeDelayed) {
705-
execute(item);
725+
// Track selection for achievements.
726+
this.kando.achievementTracker.onSelectionMade(
727+
Math.min(Math.max(pathArray.length, 1), 3) as 1 | 2 | 3, // depth between 1 and 3
728+
time,
729+
source
730+
);
731+
732+
this.lastSelections.push({ time, date: new Date() });
733+
if (this.lastSelections.length > 10) {
734+
this.lastSelections.shift();
706735
}
707-
});
708736

709-
// Call the provided callbacks if they exist.
710-
const pathArray = this.pathToArray(path);
711-
this.ipcCallbacks.onSelect(pathArray);
712-
713-
// Track selection for achievements.
714-
this.kando.achievementTracker.onSelectionMade(
715-
Math.min(Math.max(pathArray.length, 1), 3) as 1 | 2 | 3, // depth between 1 and 3
716-
time,
717-
source
718-
);
719-
720-
this.lastSelections.push({ time, date: new Date() });
721-
if (this.lastSelections.length > 10) {
722-
this.lastSelections.shift();
723-
}
737+
// Check for many-selections-streak achievement.
738+
if (this.lastSelections.length === 10) {
739+
const oldest = this.lastSelections[0];
740+
const newest = this.lastSelections[9];
741+
const timeDiff = newest.date.getTime() - oldest.date.getTime();
724742

725-
// Check for many-selections-streak achievement.
726-
if (this.lastSelections.length === 10) {
727-
const oldest = this.lastSelections[0];
728-
const newest = this.lastSelections[9];
729-
const timeDiff = newest.date.getTime() - oldest.date.getTime();
743+
if (timeDiff <= 30000) {
744+
this.kando.achievementTracker.incrementStat('manySelectionsStreaks1');
745+
}
730746

731-
if (timeDiff <= 30000) {
732-
this.kando.achievementTracker.incrementStat('manySelectionsStreaks1');
733-
}
747+
if (timeDiff <= 20000) {
748+
this.kando.achievementTracker.incrementStat('manySelectionsStreaks2');
749+
}
734750

735-
if (timeDiff <= 20000) {
736-
this.kando.achievementTracker.incrementStat('manySelectionsStreaks2');
751+
if (timeDiff <= 10000) {
752+
this.kando.achievementTracker.incrementStat('manySelectionsStreaks3');
753+
}
737754
}
738755

739-
if (timeDiff <= 10000) {
740-
this.kando.achievementTracker.incrementStat('manySelectionsStreaks3');
756+
// Check for the speedy-selections-streak achievement.
757+
if (this.lastSelections.length === 10) {
758+
let average = 0.0;
759+
this.lastSelections.forEach((selection) => {
760+
average += selection.time / this.lastSelections.length;
761+
});
762+
if (average < 750) {
763+
this.kando.achievementTracker.incrementStat('speedySelectionsStreaks1');
764+
}
765+
if (average < 500) {
766+
this.kando.achievementTracker.incrementStat('speedySelectionsStreaks2');
767+
}
768+
if (average < 250) {
769+
this.kando.achievementTracker.incrementStat('speedySelectionsStreaks3');
770+
}
741771
}
742772
}
743773

744-
// Check for the speedy-selections-streak achievement.
745-
if (this.lastSelections.length === 10) {
746-
let average = 0.0;
747-
this.lastSelections.forEach((selection) => {
748-
average += selection.time / this.lastSelections.length;
749-
});
750-
if (average < 750) {
751-
this.kando.achievementTracker.incrementStat('speedySelectionsStreaks1');
752-
}
753-
if (average < 500) {
754-
this.kando.achievementTracker.incrementStat('speedySelectionsStreaks2');
755-
}
756-
if (average < 250) {
757-
this.kando.achievementTracker.incrementStat('speedySelectionsStreaks3');
758-
}
759-
}
774+
// Call the provided callbacks if they exist.
775+
this.ipcCallbacks.onSelect(target, pathArray);
760776
}
761777
);
762778

763779
// When the user hovers a menu item, we report this to whoever requested the menu.
764-
ipcMain.on('menu-window.hover-item', (event, path) => {
765-
this.ipcCallbacks.onHover(this.pathToArray(path));
780+
ipcMain.on('menu-window.hover-item', (event, target, path) => {
781+
this.ipcCallbacks.onHover(target, this.pathToArray(path));
766782
});
767783

768784
// We do not hide the window immediately when the user aborts a selection. Instead, we

0 commit comments

Comments
 (0)