From 69761fd2168da4f6478732fd2464964a11345886 Mon Sep 17 00:00:00 2001 From: yar2000T Date: Wed, 24 Dec 2025 18:54:17 +0200 Subject: [PATCH 1/6] Added implementation --- locales/en/translation.json | 2 + src/common/index.ts | 5 ++ .../settings-schemata/menu-settings-v1.ts | 6 ++ src/main/menu-window.ts | 1 + .../input-methods/input-method.ts | 1 + .../input-methods/pointer-input.ts | 18 +++++ src/menu-renderer/menu.ts | 66 +++++++++++++++++++ .../menu-properties/MenuBehavior.tsx | 11 ++++ 8 files changed, 110 insertions(+) diff --git a/locales/en/translation.json b/locales/en/translation.json index 274adf698..f132058ae 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -233,6 +233,8 @@ "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", "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..8a162b36c 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -220,6 +220,11 @@ 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..62eaa9c8e 100644 --- a/src/common/settings-schemata/menu-settings-v1.ts +++ b/src/common/settings-schemata/menu-settings-v1.ts @@ -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..c7b731f6a 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, }, { 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..551345ed8 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..10156377e 100644 --- a/src/menu-renderer/menu.ts +++ b/src/menu-renderer/menu.ts @@ -28,6 +28,9 @@ 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 (by menu root path). */ +const lastSelectedItemByMenu: Map = new Map(); + /** * 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 +368,14 @@ export class Menu extends EventEmitter { return; } + if (type === SelectionType.eRepeatLastAction) { + // Only execute repeat action if it's enabled for this menu + if (this.showMenuOptions.repeatLastAction) { + this.repeatLastAction(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 +712,9 @@ 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 menuKey = this.root.path || '/'; + lastSelectedItemByMenu.set(menuKey, item.path); this.emit('select', item.path, Date.now() - this.menuShownTime, source); } } @@ -727,6 +741,49 @@ 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. + */ + private repeatLastAction(source: SelectionSource, coords?: Vec2) { + // Check if repeat last action is enabled for this menu + const menuKey = this.root.path || '/'; + const lastActionPath = lastSelectedItemByMenu.get(menuKey); + + 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 +1280,12 @@ 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/MenuBehavior.tsx b/src/settings-renderer/components/menu-properties/MenuBehavior.tsx index 6af832717..2faa59954 100644 --- a/src/settings-renderer/components/menu-properties/MenuBehavior.tsx +++ b/src/settings-renderer/components/menu-properties/MenuBehavior.tsx @@ -61,6 +61,17 @@ export default function MenuBehavior() { }); }} /> + { + editMenu(selectedMenu, (menu) => { + menu.repeatLastAction = repeatLastAction; + return menu; + }); + }} + /> ); } From a925c22b66ab321a7ac255be528e598678e09b31 Mon Sep 17 00:00:00 2001 From: yar2000T Date: Wed, 24 Dec 2025 18:58:46 +0200 Subject: [PATCH 2/6] Applied prettier --- src/common/index.ts | 4 +--- src/common/settings-schemata/menu-settings-v1.ts | 4 ++-- src/menu-renderer/input-methods/pointer-input.ts | 2 +- src/menu-renderer/menu.ts | 5 +++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/common/index.ts b/src/common/index.ts index 8a162b36c..b7b59575a 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -220,9 +220,7 @@ export type ShowMenuOptions = { */ readonly hoverMode: boolean; - /** - * If this is set, clicking the center of the menu will repeat the last selected action. - */ + /** If this is set, clicking the center of the menu will repeat the last selected action. */ readonly repeatLastAction: boolean; /** diff --git a/src/common/settings-schemata/menu-settings-v1.ts b/src/common/settings-schemata/menu-settings-v1.ts index 62eaa9c8e..2157009bd 100644 --- a/src/common/settings-schemata/menu-settings-v1.ts +++ b/src/common/settings-schemata/menu-settings-v1.ts @@ -117,8 +117,8 @@ 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. + * If true, clicking the center of the menu will repeat the last selected action for + * this menu. */ repeatLastAction: z.boolean().default(false), diff --git a/src/menu-renderer/input-methods/pointer-input.ts b/src/menu-renderer/input-methods/pointer-input.ts index 551345ed8..a24782b14 100644 --- a/src/menu-renderer/input-methods/pointer-input.ts +++ b/src/menu-renderer/input-methods/pointer-input.ts @@ -251,7 +251,7 @@ export class PointerInput extends InputMethod { 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( diff --git a/src/menu-renderer/menu.ts b/src/menu-renderer/menu.ts index 10156377e..a2b90dc91 100644 --- a/src/menu-renderer/menu.ts +++ b/src/menu-renderer/menu.ts @@ -742,8 +742,8 @@ export class Menu extends EventEmitter { } /** - * This method repeats the last selected action for this menu by directly selecting - * the previously selected item immediately. + * 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. @@ -1283,6 +1283,7 @@ export class Menu extends EventEmitter { /** * 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. */ From 78ac4a6380a9ea765a16b7c7b7c7c0c48dd0dbff Mon Sep 17 00:00:00 2001 From: yar2000T Date: Thu, 25 Dec 2025 22:53:00 +0200 Subject: [PATCH 3/6] Added options for submenus --- locales/en/translation.json | 10 ++ .../settings-schemata/menu-settings-v1.ts | 2 +- src/menu-renderer/menu.ts | 136 +++++++++++++++++- .../item-configs/submenu-item-config.tsx | 81 ++++++++--- 4 files changed, 208 insertions(+), 21 deletions(-) diff --git a/locales/en/translation.json b/locales/en/translation.json index f132058ae..df61fee3c 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -235,6 +235,16 @@ "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/settings-schemata/menu-settings-v1.ts b/src/common/settings-schemata/menu-settings-v1.ts index 2157009bd..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(), diff --git a/src/menu-renderer/menu.ts b/src/menu-renderer/menu.ts index a2b90dc91..c188bf9c4 100644 --- a/src/menu-renderer/menu.ts +++ b/src/menu-renderer/menu.ts @@ -31,6 +31,9 @@ import { SoundTheme } from './sound-theme'; /** Map to store the last selected item path for each menu (by menu root path). */ const lastSelectedItemByMenu: Map = new Map(); +/** Map to store the last selected item path in each submenu. */ +const lastSelectedItemBySubmenu: Map = new Map(); + /** * 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 @@ -369,10 +372,8 @@ export class Menu extends EventEmitter { } if (type === SelectionType.eRepeatLastAction) { - // Only execute repeat action if it's enabled for this menu - if (this.showMenuOptions.repeatLastAction) { - this.repeatLastAction(source, coords); - } + // Handle center action configuration + this.handleCenterAction(source, coords); return; } @@ -715,6 +716,14 @@ export class Menu extends EventEmitter { // Store the last selected item path for this menu's repeat functionality const menuKey = this.root.path || '/'; lastSelectedItemByMenu.set(menuKey, item.path); + + // Also track last item selected in the current submenu + const activeItem = this.selectionChain[this.selectionChain.length - 1]; + if (activeItem) { + const submenuKey = activeItem.path || '/'; + lastSelectedItemBySubmenu.set(submenuKey, item.path); + } + this.emit('select', item.path, Date.now() - this.menuShownTime, source); } } @@ -749,7 +758,47 @@ export class Menu extends EventEmitter { * 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 + : '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, coords); + } 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 name + const childName = centerActionValue.substring(6); // Remove 'child:' prefix + const child = activeItem?.children?.find((c) => c.name === childName); + if (child) { + this.selectItem(child, source, coords); + } + } + } + private repeatLastAction(source: SelectionSource, coords?: Vec2) { // Check if repeat last action is enabled for this menu const menuKey = this.root.path || '/'; @@ -784,6 +833,85 @@ export class Menu extends EventEmitter { } } + /** + * Repeats the last action executed in the current menu (root menu). + */ + private repeatLastActionInMenu(source: SelectionSource, coords?: Vec2) { + const menuKey = this.root.path || '/'; + const lastActionPath = lastSelectedItemByMenu.get(menuKey); + + 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 + const activeItem = this.selectionChain[this.selectionChain.length - 1]; + if (!activeItem) { + return; + } + + const submenuKey = activeItem.path || '/'; + 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. 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..e345c0f1b 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,74 @@ import React from 'react'; import i18next from 'i18next'; - -import { RandomTip } from '../../common'; +import { useMenuSettings, useAppState, getSelectedChild } from '../../../state'; +import { Dropdown } from '../../common'; /** - * The configuration component for submenu items is quite simple - it only shows a random - * tip of the day. + * The configuration component for submenu items allows configuring the center action. */ -export default () => { +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) => ({ + value: `child:${child.name}`, + 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 }; + }); + }; + return ( - ); -}; +} From 5bd9dc74cc928c31f314ec03fc998dac8b2ba5ae Mon Sep 17 00:00:00 2001 From: yar2000T Date: Fri, 26 Dec 2025 13:44:19 +0200 Subject: [PATCH 4/6] Fixed code, added support for central menu node --- src/main/menu-window.ts | 143 +++++++++++++++++- src/menu-renderer/menu.ts | 80 ++++------ .../menu-properties/MenuBehavior.tsx | 12 +- .../components/menu-properties/Properties.tsx | 38 +++++ .../item-configs/submenu-item-config.tsx | 9 +- 5 files changed, 212 insertions(+), 70 deletions(-) diff --git a/src/main/menu-window.ts b/src/main/menu-window.ts index c7b731f6a..c2d3c06fe 100644 --- a/src/main/menu-window.ts +++ b/src/main/menu-window.ts @@ -617,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. @@ -641,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/menu.ts b/src/menu-renderer/menu.ts index c188bf9c4..f26c7436b 100644 --- a/src/menu-renderer/menu.ts +++ b/src/menu-renderer/menu.ts @@ -28,12 +28,15 @@ 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 (by menu root path). */ +/** 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. */ +/** 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 @@ -714,16 +717,19 @@ 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 menuKey = this.root.path || '/'; - lastSelectedItemByMenu.set(menuKey, item.path); - - // Also track last item selected in the current submenu - const activeItem = this.selectionChain[this.selectionChain.length - 1]; - if (activeItem) { - const submenuKey = activeItem.path || '/'; + 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); } } @@ -790,9 +796,9 @@ export class Menu extends EventEmitter { // Repeat last action in current submenu this.repeatLastActionInSubmenu(source, coords); } else if (centerActionValue.startsWith('child:')) { - // Select a specific child by name - const childName = centerActionValue.substring(6); // Remove 'child:' prefix - const child = activeItem?.children?.find((c) => c.name === childName); + // 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); } @@ -800,36 +806,13 @@ export class Menu extends EventEmitter { } private repeatLastAction(source: SelectionSource, coords?: Vec2) { - // Check if repeat last action is enabled for this menu - const menuKey = this.root.path || '/'; - const lastActionPath = lastSelectedItemByMenu.get(menuKey); - - 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); + // 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 + (window as any).menuAPI.selectItem(`${menuName}::${path}`, 0, source); } } @@ -837,8 +820,8 @@ export class Menu extends EventEmitter { * Repeats the last action executed in the current menu (root menu). */ private repeatLastActionInMenu(source: SelectionSource, coords?: Vec2) { - const menuKey = this.root.path || '/'; - const lastActionPath = lastSelectedItemByMenu.get(menuKey); + const menuName = this.root.name || '/'; + const lastActionPath = lastSelectedItemByMenu.get(menuName); if (!lastActionPath) { return; @@ -873,13 +856,16 @@ export class Menu extends EventEmitter { * Repeats the last action executed in the current submenu. */ private repeatLastActionInSubmenu(source: SelectionSource, coords?: Vec2) { - // Get the current submenu item + // Get the current submenu item (or root if at root level) const activeItem = this.selectionChain[this.selectionChain.length - 1]; if (!activeItem) { return; } - const submenuKey = activeItem.path || '/'; + 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) { diff --git a/src/settings-renderer/components/menu-properties/MenuBehavior.tsx b/src/settings-renderer/components/menu-properties/MenuBehavior.tsx index 2faa59954..4f5355e8b 100644 --- a/src/settings-renderer/components/menu-properties/MenuBehavior.tsx +++ b/src/settings-renderer/components/menu-properties/MenuBehavior.tsx @@ -61,17 +61,7 @@ export default function MenuBehavior() { }); }} /> - { - editMenu(selectedMenu, (menu) => { - menu.repeatLastAction = repeatLastAction; - return menu; - }); - }} - /> + ); } diff --git a/src/settings-renderer/components/menu-properties/Properties.tsx b/src/settings-renderer/components/menu-properties/Properties.tsx index 89efccfc6..745555971 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,36 @@ 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 any).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 +193,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 e345c0f1b..5ba9788d5 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 @@ -30,10 +30,11 @@ export default function SubmenuItemConfig() { } // Center action options - const childOptions = (selectedItem.children || []).map((child) => ({ - value: `child:${child.name}`, - label: child.name, - })); + 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 = [ { From 5333e93ec9182545c731094c17cf6e89610235a9 Mon Sep 17 00:00:00 2001 From: yar2000T Date: Fri, 26 Dec 2025 13:51:48 +0200 Subject: [PATCH 5/6] Applied prettier --- src/main/menu-window.ts | 22 ++++----- src/menu-renderer/menu.ts | 49 +++++++++++-------- .../menu-properties/MenuBehavior.tsx | 1 - .../components/menu-properties/Properties.tsx | 28 +++++++---- .../item-configs/submenu-item-config.tsx | 17 ++++--- 5 files changed, 68 insertions(+), 49 deletions(-) diff --git a/src/main/menu-window.ts b/src/main/menu-window.ts index c2d3c06fe..146445184 100644 --- a/src/main/menu-window.ts +++ b/src/main/menu-window.ts @@ -619,18 +619,18 @@ export class MenuWindow extends BrowserWindow { try { // 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 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.`); - } + 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. diff --git a/src/menu-renderer/menu.ts b/src/menu-renderer/menu.ts index f26c7436b..87a7bac2b 100644 --- a/src/menu-renderer/menu.ts +++ b/src/menu-renderer/menu.ts @@ -31,7 +31,10 @@ 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 "::"). */ +/** + * 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. */ @@ -724,7 +727,10 @@ export class Menu extends EventEmitter { 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; + 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); @@ -763,20 +769,21 @@ export class Menu extends EventEmitter { * @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. - * + * 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 - : 'default'; + const centerActionValue = + activeItem?.data && + typeof activeItem.data === 'object' && + 'centerAction' in activeItem.data + ? activeItem.data.centerAction + : 'default'; // Handle different center action types if (centerActionValue === 'default') { @@ -788,7 +795,7 @@ export class Menu extends EventEmitter { } } else if (centerActionValue === 'repeat-global') { // Repeat last action globally - this.repeatLastAction(source, coords); + this.repeatLastAction(source); } else if (centerActionValue === 'repeat-menu') { // Repeat last action in current menu this.repeatLastActionInMenu(source, coords); @@ -805,20 +812,23 @@ export class Menu extends EventEmitter { } } - private repeatLastAction(source: SelectionSource, coords?: Vec2) { + 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 - (window as any).menuAPI.selectItem(`${menuName}::${path}`, 0, source); + 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). - */ + /** 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); @@ -852,9 +862,7 @@ export class Menu extends EventEmitter { } } - /** - * Repeats the last action executed in the current submenu. - */ + /** 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]; @@ -864,7 +872,7 @@ export class Menu extends EventEmitter { const menuName = this.root.name || '/'; // For root menu, use '/' as the key - const submenuPath = activeItem === this.root ? '/' : (activeItem.path || '/'); + const submenuPath = activeItem === this.root ? '/' : activeItem.path || '/'; const submenuKey = `${menuName}::${submenuPath}`; const lastActionPath = lastSelectedItemBySubmenu.get(submenuKey); @@ -897,7 +905,6 @@ export class Menu extends EventEmitter { } } - /** * 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. diff --git a/src/settings-renderer/components/menu-properties/MenuBehavior.tsx b/src/settings-renderer/components/menu-properties/MenuBehavior.tsx index 4f5355e8b..6af832717 100644 --- a/src/settings-renderer/components/menu-properties/MenuBehavior.tsx +++ b/src/settings-renderer/components/menu-properties/MenuBehavior.tsx @@ -61,7 +61,6 @@ export default function MenuBehavior() { }); }} /> - ); } diff --git a/src/settings-renderer/components/menu-properties/Properties.tsx b/src/settings-renderer/components/menu-properties/Properties.tsx index 745555971..d57825b3d 100644 --- a/src/settings-renderer/components/menu-properties/Properties.tsx +++ b/src/settings-renderer/components/menu-properties/Properties.tsx @@ -52,11 +52,13 @@ export default function Properties() { }, [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 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 = [ { @@ -69,13 +71,19 @@ export default function Properties() { ...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 any).centerAction) - : 'default'; + 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 }; + const newData = { + ...(menu.root.data && typeof menu.root.data === 'object' ? menu.root.data : {}), + centerAction: value, + }; menu.root.data = newData; return menu; }); @@ -194,9 +202,9 @@ export default function Properties() { 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 5ba9788d5..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 @@ -13,9 +13,7 @@ import i18next from 'i18next'; import { useMenuSettings, useAppState, getSelectedChild } from '../../../state'; import { Dropdown } from '../../common'; -/** - * The configuration component for submenu items allows configuring the center action. - */ +/** 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); @@ -61,22 +59,29 @@ export default function SubmenuItemConfig() { // Get/set centerAction from item.data const centerAction = - selectedItem.data && typeof selectedItem.data === 'object' && 'centerAction' in selectedItem.data + 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 }; + const newData = { + ...(typeof oldItem.data === 'object' && oldItem.data !== null + ? oldItem.data + : {}), + centerAction: value, + }; return { ...oldItem, data: newData }; }); }; return ( From 8bafbbd8a90ac67beec6ae8b5ceadba36422f1e1 Mon Sep 17 00:00:00 2001 From: yar2000T Date: Fri, 26 Dec 2025 14:03:29 +0200 Subject: [PATCH 6/6] Fixed typescript --- src/menu-renderer/menu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/menu-renderer/menu.ts b/src/menu-renderer/menu.ts index 87a7bac2b..6a96176a3 100644 --- a/src/menu-renderer/menu.ts +++ b/src/menu-renderer/menu.ts @@ -782,7 +782,7 @@ export class Menu extends EventEmitter { activeItem?.data && typeof activeItem.data === 'object' && 'centerAction' in activeItem.data - ? activeItem.data.centerAction + ? (activeItem.data.centerAction as string) : 'default'; // Handle different center action types