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
12 changes: 12 additions & 0 deletions locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/common/settings-schemata/menu-settings-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Expand Down Expand Up @@ -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.
Expand Down
144 changes: 136 additions & 8 deletions src/main/menu-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
{
Expand Down Expand Up @@ -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 "<menuName>::<path>".
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.
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/menu-renderer/input-methods/input-method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export enum SelectionType {
eActiveItem,
eSubmenuOnly,
eParent,
eRepeatLastAction,
}

/**
Expand Down
18 changes: 18 additions & 0 deletions src/menu-renderer/input-methods/pointer-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading