diff --git a/locales/en/translation.json b/locales/en/translation.json index 274adf698..df61fee3c 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -233,6 +233,18 @@ "anchored-mode": "Anchored Mode", "hover-mode-info": "For power users only! Select items by hovering over them.", "hover-mode": "Hover Mode", + "repeat-last-action-info": "Click the center of the menu to repeat the last selected action.", + "repeat-last-action": "Repeat Last Action", + "centerAction": { + "label": "Center Action", + "tip": "Configure what happens when you click the center of this menu.", + "closeMenu": "Close Menu", + "goToParent": "Go to Parent Menu", + "repeatGlobal": "Execute Previous Action (Last Global)", + "repeatMenu": "Execute Previous Action (Last in Menu)", + "repeatSubmenu": "Execute Previous Action (Last in Submenu)" + }, + "child": "Child", "menu-conditions": "Menu Conditions", "menu-conditions-info": "You can bind multiple menus to the same shortcut and then choose under which conditions each menu should be shown.", "app-condition-info": "Show the menu only if a specific application is focused. This supports regular expressions like /firefox|chrome/i.", diff --git a/src/common/index.ts b/src/common/index.ts index 7e53b3fed..b7b59575a 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -220,6 +220,9 @@ export type ShowMenuOptions = { */ readonly hoverMode: boolean; + /** If this is set, clicking the center of the menu will repeat the last selected action. */ + readonly repeatLastAction: boolean; + /** * If this is set, the system-icon theme has changed since the last time the menu was * opened. This is used to determine if the menu needs to be reloaded. diff --git a/src/common/settings-schemata/menu-settings-v1.ts b/src/common/settings-schemata/menu-settings-v1.ts index 7107f49c2..b81207839 100644 --- a/src/common/settings-schemata/menu-settings-v1.ts +++ b/src/common/settings-schemata/menu-settings-v1.ts @@ -44,7 +44,7 @@ export const MENU_ITEM_SCHEMA_V1 = z.object({ /** * The data of the menu item. What this contains depends on the type. Usually, only leaf - * menu items will have this field. + * menu items will have this field. For submenu items, this may include centerAction. */ data: z.unknown().nullish(), @@ -116,6 +116,12 @@ export const MENU_SCHEMA_V1 = z.object({ */ hoverMode: z.boolean().default(false), + /** + * If true, clicking the center of the menu will repeat the last selected action for + * this menu. + */ + repeatLastAction: z.boolean().default(false), + /** * Conditions are matched before showing a menu. The one that has more conditions and * met them all is selected. diff --git a/src/main/menu-window.ts b/src/main/menu-window.ts index 9b8d39b40..146445184 100644 --- a/src/main/menu-window.ts +++ b/src/main/menu-window.ts @@ -266,6 +266,7 @@ export class MenuWindow extends BrowserWindow { centeredMode: this.lastMenu.centered, anchoredMode: this.lastMenu.anchored, hoverMode: this.lastMenu.hoverMode, + repeatLastAction: this.lastMenu.repeatLastAction, systemIconsChanged, }, { @@ -616,7 +617,91 @@ export class MenuWindow extends BrowserWindow { let executeDelayed = false; try { - // Find the selected item. + // Find the selected item. Support cross-menu invocation using the format "::". + if (typeof path === 'string' && path.indexOf('::') !== -1) { + const sep = path.indexOf('::'); + const menuName = path.substring(0, sep); + const itemPath = path.substring(sep + 2); + + const menus = this.kando.getMenuSettings().get('menus'); + if (!Array.isArray(menus)) { + throw new Error('Menu settings corrupted.'); + } + const targetMenu = menus.find((m) => m.root.name === menuName); + if (!targetMenu) { + throw new Error(`Menu with name "${menuName}" not found.`); + } + item = this.getMenuItemAtPath(targetMenu.root, itemPath); + + // If the action is not delayed, we execute it immediately. + executeDelayed = ItemActionRegistry.getInstance().delayedExecution(item); + if (!executeDelayed) { + execute(item); + } + + // 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 (executeDelayed) { + execute(item); + } + }); + + // Track selection for achievements using the itemPath (not the composite path) + this.kando.achievementTracker.onSelectionMade( + Math.min(Math.max(itemPath.split('/').length - 1, 1), 3) as 1 | 2 | 3, + time, + source + ); + + // Push last selection metadata + this.lastSelections.push({ time, date: new Date() }); + if (this.lastSelections.length > 10) { + this.lastSelections.shift(); + } + + // Check achievements as before (duplicated logic kept for cross-menu path) + if (this.lastSelections.length === 10) { + const oldest = this.lastSelections[0]; + const newest = this.lastSelections[9]; + const timeDiff = newest.date.getTime() - oldest.date.getTime(); + + if (timeDiff <= 30000) { + this.kando.achievementTracker.incrementStat('manySelectionsStreaks1'); + } + + if (timeDiff <= 20000) { + this.kando.achievementTracker.incrementStat('manySelectionsStreaks2'); + } + + if (timeDiff <= 10000) { + this.kando.achievementTracker.incrementStat('manySelectionsStreaks3'); + } + } + + // Speedy selections check + if (this.lastSelections.length === 10) { + let average = 0.0; + this.lastSelections.forEach((selection) => { + average += selection.time / this.lastSelections.length; + }); + if (average < 750) { + this.kando.achievementTracker.incrementStat('speedySelectionsStreaks1'); + } + if (average < 500) { + this.kando.achievementTracker.incrementStat('speedySelectionsStreaks2'); + } + if (average < 250) { + this.kando.achievementTracker.incrementStat('speedySelectionsStreaks3'); + } + } + + // We're done handling the cross-menu selection. + return; + } + + // Default behavior: selection inside the currently shown menu item = this.getMenuItemAtPath(this.lastMenu.root, path); // If the action is not delayed, we execute it immediately. @@ -640,14 +725,57 @@ export class MenuWindow extends BrowserWindow { if (executeDelayed) { execute(item); } - }); - // Track selection for achievements. - this.kando.achievementTracker.onSelectionMade( - Math.min(Math.max(path.split('/').length - 1, 1), 3) as 1 | 2 | 3, // depth between 1 and 3 - time, - source - ); + // Track selection for achievements (default path behavior) + if (!path || path.indexOf('::') === -1) { + this.kando.achievementTracker.onSelectionMade( + Math.min(Math.max(path.split('/').length - 1, 1), 3) as 1 | 2 | 3, // depth between 1 and 3 + time, + source + ); + + this.lastSelections.push({ time, date: new Date() }); + if (this.lastSelections.length > 10) { + this.lastSelections.shift(); + } + + // Check for many-selections-streak achievement. + if (this.lastSelections.length === 10) { + const oldest = this.lastSelections[0]; + const newest = this.lastSelections[9]; + const timeDiff = newest.date.getTime() - oldest.date.getTime(); + + if (timeDiff <= 30000) { + this.kando.achievementTracker.incrementStat('manySelectionsStreaks1'); + } + + if (timeDiff <= 20000) { + this.kando.achievementTracker.incrementStat('manySelectionsStreaks2'); + } + + if (timeDiff <= 10000) { + this.kando.achievementTracker.incrementStat('manySelectionsStreaks3'); + } + } + + // Check for the speedy-selections-streak achievement. + if (this.lastSelections.length === 10) { + let average = 0.0; + this.lastSelections.forEach((selection) => { + average += selection.time / this.lastSelections.length; + }); + if (average < 750) { + this.kando.achievementTracker.incrementStat('speedySelectionsStreaks1'); + } + if (average < 500) { + this.kando.achievementTracker.incrementStat('speedySelectionsStreaks2'); + } + if (average < 250) { + this.kando.achievementTracker.incrementStat('speedySelectionsStreaks3'); + } + } + } + }); this.lastSelections.push({ time, date: new Date() }); if (this.lastSelections.length > 10) { diff --git a/src/menu-renderer/input-methods/input-method.ts b/src/menu-renderer/input-methods/input-method.ts index 036ad4f78..363d59561 100644 --- a/src/menu-renderer/input-methods/input-method.ts +++ b/src/menu-renderer/input-methods/input-method.ts @@ -30,6 +30,7 @@ export enum SelectionType { eActiveItem, eSubmenuOnly, eParent, + eRepeatLastAction, } /** diff --git a/src/menu-renderer/input-methods/pointer-input.ts b/src/menu-renderer/input-methods/pointer-input.ts index baa59d7ce..a24782b14 100644 --- a/src/menu-renderer/input-methods/pointer-input.ts +++ b/src/menu-renderer/input-methods/pointer-input.ts @@ -245,6 +245,24 @@ export class PointerInput extends InputMethod { return; } + // Repeat last action on left click on the center (when no item is hovered). + if ((event as MouseEvent).button === 0) { + const distance = Math.sqrt( + Math.pow(this.pointerPosition.x - this.centerPosition.x, 2) + + Math.pow(this.pointerPosition.y - this.centerPosition.y, 2) + ); + + // If clicking very close to the center with no hovered item, trigger repeat action + if (distance < this.centerRadius * 0.5) { + this.selectCallback( + this.pointerPosition, + SelectionType.eRepeatLastAction, + SelectionSource.eClick + ); + return; + } + } + if (event instanceof MouseEvent) { this.clickPosition = { x: event.clientX, y: event.clientY }; } else { diff --git a/src/menu-renderer/menu.ts b/src/menu-renderer/menu.ts index ee7184c0f..6a96176a3 100644 --- a/src/menu-renderer/menu.ts +++ b/src/menu-renderer/menu.ts @@ -28,6 +28,18 @@ import { ButtonState, InputState, SelectionType } from './input-methods/input-me import { MenuTheme } from './menu-theme'; import { SoundTheme } from './sound-theme'; +/** Map to store the last selected item path for each menu (keyed by menu name). */ +const lastSelectedItemByMenu: Map = new Map(); + +/** + * Map to store the last selected item path in each submenu (keyed by + * "::"). + */ +const lastSelectedItemBySubmenu: Map = new Map(); + +/** Global last selected item across all menus. */ +let lastSelectedGlobal: { menuName: string; path: string } | null = null; + /** * The menu is the main class of Kando. It stores a tree of items which is used to render * the menu. The menu is shown by calling the show() method and hidden by calling the @@ -365,6 +377,12 @@ export class Menu extends EventEmitter { return; } + if (type === SelectionType.eRepeatLastAction) { + // Handle center action configuration + this.handleCenterAction(source, coords); + return; + } + // If there is an item currently dragged, select it. If we are in Marking Mode or // Turbo Mode, the selection type will be eSubmenuOnly. In this case, we only select // submenus in order to prevent unwanted actions. This way the user can always check @@ -701,6 +719,23 @@ export class Menu extends EventEmitter { if (item.type !== 'submenu') { this.container.classList.add('selected'); + // Store the last selected item path for this menu's repeat functionality + const menuName = this.root.name || '/'; + lastSelectedItemByMenu.set(menuName, item.path); + + // Update global last selected (for Execute Previous Action - Global) + lastSelectedGlobal = { menuName, path: item.path }; + + // Also track last item selected in the parent submenu (keyed by menuName::parentPath) + const parent = + this.selectionChain.length >= 2 + ? this.selectionChain[this.selectionChain.length - 2] + : null; + if (parent && parent.type === 'submenu') { + const submenuKey = `${menuName}::${parent.path || '/'}`; + lastSelectedItemBySubmenu.set(submenuKey, item.path); + } + this.emit('select', item.path, Date.now() - this.menuShownTime, source); } } @@ -727,6 +762,149 @@ export class Menu extends EventEmitter { } } + /** + * This method repeats the last selected action for this menu by directly selecting the + * previously selected item immediately. + * + * @param source The input method which was used to make the selection. Used for + * achievement tracking. + * @param coords The position where the selection most likely happened. If it is not + * given, the latest pointer input position is used. /** This handles center action + * when the center of the menu is clicked. It checks the center action configuration + * of the current submenu and acts accordingly. + * @param source The input source (mouse, gamepad, etc.) + * @param coords The coordinates of the selection + */ + private handleCenterAction(source: SelectionSource, coords?: Vec2) { + // Get the center action from the current submenu item's data + const activeItem = this.selectionChain[this.selectionChain.length - 1]; + const centerActionValue = + activeItem?.data && + typeof activeItem.data === 'object' && + 'centerAction' in activeItem.data + ? (activeItem.data.centerAction as string) + : 'default'; + + // Handle different center action types + if (centerActionValue === 'default') { + // Default: close menu or go to parent + if (this.selectionChain.length === 1) { + this.cancel(); + } else { + this.selectParent(source, coords); + } + } else if (centerActionValue === 'repeat-global') { + // Repeat last action globally + this.repeatLastAction(source); + } else if (centerActionValue === 'repeat-menu') { + // Repeat last action in current menu + this.repeatLastActionInMenu(source, coords); + } else if (centerActionValue === 'repeat-submenu') { + // Repeat last action in current submenu + this.repeatLastActionInSubmenu(source, coords); + } else if (centerActionValue.startsWith('child:')) { + // Select a specific child by index (handles duplicate names) + const childIndex = parseInt(centerActionValue.substring(6), 10); // Remove 'child:' prefix + const child = activeItem?.children?.[childIndex]; + if (child) { + this.selectItem(child, source, coords); + } + } + } + + private repeatLastAction(source: SelectionSource): void { + // Execute the last global action if available + if (lastSelectedGlobal) { + const { menuName, path } = lastSelectedGlobal; + // Hide the menu before executing the cross-menu action + this.hide(); + // Use the main process to execute the specified menu item across menus + const menuAPI = window as unknown as { + menuAPI: { + selectItem: (path: string, time: number, source: SelectionSource) => void; + }; + }; + menuAPI.menuAPI.selectItem(`${menuName}::${path}`, 0, source); + } + } + + /** Repeats the last action executed in the current menu (root menu). */ + private repeatLastActionInMenu(source: SelectionSource, coords?: Vec2) { + const menuName = this.root.name || '/'; + const lastActionPath = lastSelectedItemByMenu.get(menuName); + + if (!lastActionPath) { + return; + } + + // Parse the path to get the indices + const pathSegments = lastActionPath + .split('/') + .filter((s) => s) + .map((s) => parseInt(s, 10)); + + if (pathSegments.length === 0) { + return; + } + + // Navigate to the item using the path + let currentItem = this.root; + for (const index of pathSegments) { + if (!currentItem.children || !currentItem.children[index]) { + return; + } + currentItem = currentItem.children[index]; + } + + // Select the item if it's not a submenu + if (currentItem && currentItem.type !== 'submenu') { + this.selectItem(currentItem, source, coords); + } + } + + /** Repeats the last action executed in the current submenu. */ + private repeatLastActionInSubmenu(source: SelectionSource, coords?: Vec2) { + // Get the current submenu item (or root if at root level) + const activeItem = this.selectionChain[this.selectionChain.length - 1]; + if (!activeItem) { + return; + } + + const menuName = this.root.name || '/'; + // For root menu, use '/' as the key + const submenuPath = activeItem === this.root ? '/' : activeItem.path || '/'; + const submenuKey = `${menuName}::${submenuPath}`; + const lastActionPath = lastSelectedItemBySubmenu.get(submenuKey); + + if (!lastActionPath) { + return; + } + + // Parse the path to get the indices + const pathSegments = lastActionPath + .split('/') + .filter((s) => s) + .map((s) => parseInt(s, 10)); + + if (pathSegments.length === 0) { + return; + } + + // Navigate to the item using the path + let currentItem = this.root; + for (const index of pathSegments) { + if (!currentItem.children || !currentItem.children[index]) { + return; + } + currentItem = currentItem.children[index]; + } + + // Select the item if it's not a submenu + if (currentItem && currentItem.type !== 'submenu') { + this.selectItem(currentItem, source, coords); + } + } + /** * This will assign the CSS class 'hovered' to the given menu item's node div element. * It will also remove the class from the previously hovered menu item. @@ -1223,3 +1401,13 @@ export class Menu extends EventEmitter { return this.showMenuOptions.mousePosition; } } + +/** + * Gets the last selected item path for a specific menu. + * + * @param menuKey The path or key of the menu + * @returns The path of the last selected item, or null if no item has been selected. + */ +export function getLastSelectedItemPathForMenu(menuKey: string): string | null { + return lastSelectedItemByMenu.get(menuKey) || null; +} diff --git a/src/settings-renderer/components/menu-properties/Properties.tsx b/src/settings-renderer/components/menu-properties/Properties.tsx index 89efccfc6..d57825b3d 100644 --- a/src/settings-renderer/components/menu-properties/Properties.tsx +++ b/src/settings-renderer/components/menu-properties/Properties.tsx @@ -24,6 +24,7 @@ import { Swirl, Scrollbox, TextInput, + Dropdown, } from '../common'; import { getConfigComponent } from './item-configs'; import MenuConditions from './MenuConditions'; @@ -50,6 +51,44 @@ export default function Properties() { setMenuTags(menus[selectedMenu]?.tags || []); }, [selectedMenu, menus]); + // Center action options for the root menu + const rootChildOptions = (menus[selectedMenu]?.root?.children || []).map( + (child, index) => { + // Use index as the unique identifier to handle duplicate child names + const value = `child:${index}`; + return { value, label: child.name }; + } + ); + + const rootCenterActionOptions = [ + { + value: 'default', + label: i18next.t('settings.centerAction.closeMenu'), + }, + { value: 'repeat-global', label: i18next.t('settings.centerAction.repeatGlobal') }, + { value: 'repeat-menu', label: i18next.t('settings.centerAction.repeatMenu') }, + { value: 'repeat-submenu', label: i18next.t('settings.centerAction.repeatSubmenu') }, + ...rootChildOptions, + ]; + + const rootCenterAction = + menus[selectedMenu]?.root?.data && + typeof menus[selectedMenu].root.data === 'object' && + 'centerAction' in menus[selectedMenu].root.data + ? String((menus[selectedMenu].root.data as Record).centerAction) + : 'default'; + + const setRootCenterAction = (value: string) => { + editMenu(selectedMenu, (menu) => { + const newData = { + ...(menu.root.data && typeof menu.root.data === 'object' ? menu.root.data : {}), + centerAction: value, + }; + menu.root.data = newData; + return menu; + }); + }; + if (selectedMenu === -1 || selectedMenu >= menus.length) { return ( <> @@ -162,6 +201,13 @@ export default function Properties() { // We also show the sections for the menu behavior and conditions. isRoot ? ( <> + diff --git a/src/settings-renderer/components/menu-properties/item-configs/submenu-item-config.tsx b/src/settings-renderer/components/menu-properties/item-configs/submenu-item-config.tsx index 2f81fabb4..171fe1ee2 100644 --- a/src/settings-renderer/components/menu-properties/item-configs/submenu-item-config.tsx +++ b/src/settings-renderer/components/menu-properties/item-configs/submenu-item-config.tsx @@ -10,25 +10,80 @@ import React from 'react'; import i18next from 'i18next'; +import { useMenuSettings, useAppState, getSelectedChild } from '../../../state'; +import { Dropdown } from '../../common'; -import { RandomTip } from '../../common'; +/** The configuration component for submenu items allows configuring the center action. */ +export default function SubmenuItemConfig() { + const menus = useMenuSettings((state) => state.menus); + const selectedMenu = useAppState((state) => state.selectedMenu); + const selectedChildPath = useAppState((state) => state.selectedChildPath); + const editMenuItem = useMenuSettings((state) => state.editMenuItem); + + // Get the currently selected menu item + const { selectedItem } = getSelectedChild(menus, selectedMenu, selectedChildPath); + + if (!selectedItem) { + return null; + } + + // Center action options + const childOptions = (selectedItem.children || []).map((child, index) => { + // Use index as the unique identifier to handle duplicate child names + const value = `child:${index}`; + return { value, label: child.name }; + }); + + const centerActionOptions = [ + { + value: 'default', + label: + selectedChildPath.length === 0 + ? i18next.t('settings.centerAction.closeMenu') + : i18next.t('settings.centerAction.goToParent'), + }, + { + value: 'repeat-global', + label: i18next.t('settings.centerAction.repeatGlobal'), + }, + { + value: 'repeat-menu', + label: i18next.t('settings.centerAction.repeatMenu'), + }, + { + value: 'repeat-submenu', + label: i18next.t('settings.centerAction.repeatSubmenu'), + }, + ...childOptions, + ]; + + // Get/set centerAction from item.data + const centerAction = + selectedItem.data && + typeof selectedItem.data === 'object' && + 'centerAction' in selectedItem.data + ? (selectedItem.data.centerAction as string) + : 'default'; + + const handleChange = (value: string) => { + editMenuItem(selectedMenu, selectedChildPath, (oldItem) => { + const newData = { + ...(typeof oldItem.data === 'object' && oldItem.data !== null + ? oldItem.data + : {}), + centerAction: value, + }; + return { ...oldItem, data: newData }; + }); + }; -/** - * The configuration component for submenu items is quite simple - it only shows a random - * tip of the day. - */ -export default () => { return ( - ); -}; +}