Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,9 @@
"delayed-option-info": "Useful if the action targets a window that needs to be focused.",
"delayed-option": "Wait for fade-out animation",
"inhibit-shortcuts-info": "Disables all Kando shortcuts while the hotkey or macro is being simulated. This prevents the simulated input from accidentally triggering other menu shortcuts.",
"inhibit-shortcuts": "Disable shortcuts"
"inhibit-shortcuts": "Disable shortcuts",
"keep-open-info": "If enabled, the menu will stay open after this item is selected. This allows you to execute the action multiple times in a row.",
"keep-open": "Keep menu open"
}
},
"achievements": {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@formkit/auto-animate": "0.8.2",
"@types/chai": "^5.2.2",
"@types/chroma-js": "^3.1.1",
"@types/fs-extra": "^11.0.4",
"@types/howler": "^2.2.12",
"@types/lodash.isequal": "^4.5.8",
"@types/mime-types": "^3.0.1",
Expand Down Expand Up @@ -65,6 +66,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-config-xo-react": "^0.29.0",
"eslint-plugin-prettier": "^5.5.4",
"fs-extra": "^11.3.4",
"globals": "^17.4.0",
"howler": "^2.2.4",
"i18next": "^25.8.13",
Expand Down
6 changes: 6 additions & 0 deletions src/common/settings-schemata/menu-settings-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export const MENU_ITEM_SCHEMA_V1 = z.object({
/** The quick-select key for selecting this menu item. */
quickSelectKey: z.string().nullish(),

/**
* If true, the menu will stay open after this item is selected. This is useful for
* items that should be executed repeatedly without closing the menu each time.
*/
keepOpen: z.boolean().optional().default(false),

/**
* The children of this menu item. If this property is set, the menu item represents a
* submenu.
Expand Down
10 changes: 6 additions & 4 deletions src/main/backends/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,12 @@ export abstract class Backend extends EventEmitter {
inhibitionID = await this.inhibitAllShortcuts();
}

await this.simulateKeysImpl(keys);

if (inhibitShortcuts) {
await this.releaseInhibition(inhibitionID);
try {
await this.simulateKeysImpl(keys);
} finally {
if (inhibitShortcuts) {
await this.releaseInhibition(inhibitionID);
}
}
}

Expand Down
68 changes: 55 additions & 13 deletions src/main/menu-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// SPDX-License-Identifier: MIT

import os from 'node:os';
import { exec } from 'node:child_process';
import { BrowserWindow, screen, ipcMain, app } from 'electron';

import { DeepReadonly } from './settings';
Expand Down Expand Up @@ -699,10 +700,48 @@ export class MenuWindow extends BrowserWindow {
// Find the selected item.
item = this.getMenuItemAtPath(this.lastMenu.root, path);

// If the action is not delayed, we execute it immediately.
executeDelayed = ItemActionRegistry.getInstance().delayedExecution(item);
if (!executeDelayed) {
execute(item);
// If the item has keepOpen set, execute the action and keep the window
// open so the user can select the item again.
if (item.keepOpen) {
// On macOS, simulated key events go to the frontmost app. Since the
// Kando window is active while the menu is open, we must first activate
// the previous app so that hotkey/macro actions reach the right target.
if (process.platform === 'darwin') {
const prevApp = this.kando.getLastWMInfo()?.appName;
console.log(`[keepOpen] prevApp: ${prevApp}`);
if (prevApp) {
console.log(`[keepOpen] Activating "${prevApp}" via open -b`);
exec(`open -b "${prevApp}"`, (err) => {
if (err) {
console.error(`[keepOpen] open -b failed:`, err.message);
} else {
console.log(`[keepOpen] App activated, executing action in 150ms`);
}
// Give the OS a moment to switch the active app, then execute.
setTimeout(() => {
console.log(`[keepOpen] Executing action for item: ${item.name}`);
execute(item);
// After key simulation completes, bring the Kando window back
// into focus so the menu remains interactive.
setTimeout(() => {
console.log(`[keepOpen] Refocusing Kando window`);
this.focus();
}, 300);
}, 150);
});
} else {
console.log(`[keepOpen] No prevApp found, executing directly`);
execute(item);
}
} else {
execute(item);
}
} else {
// If the action is not delayed, we execute it immediately.
executeDelayed = ItemActionRegistry.getInstance().delayedExecution(item);
if (!executeDelayed) {
execute(item);
}
}
} catch (error) {
Notification.show({
Expand All @@ -712,15 +751,18 @@ export class MenuWindow extends BrowserWindow {
});
}

// Also wait with the execution of the selected action until the fade-out
// animation is finished to make sure that any resulting events (such as virtual
// key presses) are not captured by the window.
this.hideWindow().then(() => {
// If the action is delayed, we execute it after the window is hidden.
if (executeDelayed) {
execute(item);
}
});
// For keepOpen items, we skip hiding the window entirely.
if (!item?.keepOpen) {
// Also wait with the execution of the selected action until the fade-out
// animation is finished to make sure that any resulting events (such as
// virtual key presses) are not captured by the window.
this.hideWindow().then(() => {
// If the action is delayed, we execute it after the window is hidden.
if (executeDelayed) {
execute(item);
}
});
}

// Track selection for achievements.
this.kando.achievementTracker.onSelectionMade(
Expand Down
8 changes: 6 additions & 2 deletions src/menu-renderer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,14 @@ Promise.all([
});

// Hide Kando's window when the user selects an item and notify the main process.
menu.on('select', (target, path, time, source) => {
if (target === 'item') {
menu.on('select', (target, path, time, source, keepOpen) => {
if (target === 'item' && !keepOpen) {
menu.hide();
settingsButton.hide();
} else if (target === 'item' && keepOpen) {
// For "keep open" items, reset the selection state so the user can
// click the item again without the menu closing.
menu.resetLeafSelection();
}
window.menuAPI.selectItem(target, path, time, source);
});
Expand Down
39 changes: 38 additions & 1 deletion src/menu-renderer/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ export class Menu extends EventEmitter {
/** The dragged item is the item which is currently dragged by the mouse. */
private draggedItem: RenderedMenuItem = null;

/**
* Stores the root's relative position just before a "keep open" leaf item is selected,
* so that resetLeafSelection() can restore the menu to its original position.
*/
private savedRootPosition: { x: number; y: number } | null = null;

/**
* The selection chain is the chain of menu items from the root item to the currently
* selected item. The first element of the array is the root item, the last element is
Expand Down Expand Up @@ -611,6 +617,15 @@ export class Menu extends EventEmitter {
// Is the item the parent of the currently active item?
const selectedParent = this.isParentOfCenterItem(item);

// If the item has keepOpen, save the root position now so we can restore it after
// the selection repositions the menu.
if (item.keepOpen && !selectedParent && item.type !== 'submenu') {
this.savedRootPosition = {
x: this.root.relativePosition?.x ?? 0,
y: this.root.relativePosition?.y ?? 0,
};
}

// If the menu item is the parent of the currently selected item, we have to pop the
// currently selected item from the list of selected menu items. If the item is a
// child of the currently selected item, we have to push it to the list of selected
Expand Down Expand Up @@ -738,11 +753,33 @@ export class Menu extends EventEmitter {
interactionTarget,
item.path,
Date.now() - this.menuShownTime,
source
source,
interactionTarget === InteractionTarget.eItem && item.keepOpen === true
);
}
}

/**
* This method removes the last selected leaf item from the selection chain and resets
* the menu to its interactive state. It is used when a "keep open" item is selected
* so that the user can select the item again without the menu closing.
*/
public resetLeafSelection() {
if (this.selectionChain.length > 1) {
this.selectionChain.pop();
}
// Restore the root position to where it was before the selection moved it,
// so the menu doesn't shift after executing a keep-open action.
if (this.savedRootPosition) {
this.root.relativePosition = this.savedRootPosition;
this.savedRootPosition = null;
}
this.container.classList.remove('selected');
this.updateCSSClasses();
this.updateConnectors();
this.redraw();
}

/**
* This method will select the parent of the currently selected item. If the currently
* selected item is the root item, the "cancel" event will be emitted.
Expand Down
18 changes: 18 additions & 0 deletions src/settings-renderer/components/menu-properties/Properties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useAppState, useMenuSettings, getSelectedChild } from '../../state';
import {
Headerbar,
Button,
Checkbox,
IconChooserButton,
TagInput,
ShortcutPicker,
Expand Down Expand Up @@ -178,6 +179,23 @@ export default function Properties() {
// item type.
!isRoot && selectedItem ? getConfigComponent(selectedItem.type) : null
}
{
// For non-submenu items, show the "keep open" option so the user can
// execute the action repeatedly without closing the menu.
!isRoot && selectedItem && selectedItem.type !== 'submenu' ? (
<Checkbox
info={i18next.t('menu-items.common.keep-open-info')}
initialValue={selectedItem.keepOpen || false}
label={i18next.t('menu-items.common.keep-open')}
onChange={(value) => {
editMenuItem(selectedMenu, selectedChildPath, (item) => {
item.keepOpen = value;
return item;
});
}}
/>
) : null
}
{
// Also, each menu item has the quick-select key. The default value for this
// is the menu item's number.
Expand Down
5 changes: 5 additions & 0 deletions webpack.renderer.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,10 @@ export const rendererConfig: Configuration = {
externals: ignores,
resolve: {
extensions: ['.js', '.ts', '.jsx', '.tsx', '.css', '.scss'],
// Node.js built-ins like 'events' are not polyfilled by webpack 5 by default.
// The renderer process uses EventEmitter from 'events', so we need a browser polyfill.
fallback: {
events: require.resolve('events/'),
},
},
};
Loading