diff --git a/CHANGELOG.md b/CHANGELOG.md index 4336eaa..5ee843a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Add color on the status bar message when key binding entered is not defined +- Add support for a new conditional type binding, which allows conditional binding execution. See README for more information on how to use it. ## [0.7.6] - 2020-08-03 ### Added diff --git a/README.md b/README.md index 6f5f7e3..0255d8f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This extension can be used by itself or be called by other extension. ### Standalone -This extension comes with a default that didn't have any third-party dependencies. +This extension comes with a default that didn't have any third-party dependencies. #### Setup: I am using VSCode Vim If you want a better default behavior design for VSCode Vim, checkout [VSpaceCode](https://github.com/VSpaceCode/VSpaceCode). @@ -46,7 +46,6 @@ You can also bind a customize menu with Vim directly { "before": [""], "commands": ["whichkey.show"], - } ] ``` @@ -101,7 +100,7 @@ The following example will replace/append the whole ` g` menu with one bind "key": "s", "name": "Go to", "type": "command", - "command": "workbench.action.gotoLine", + "command": "workbench.action.gotoLine", } ] } @@ -241,6 +240,7 @@ This section config extra settings that pertain to both Standalone or With exten This section describes a way to use non-character keys in which-key menu like `` or `Control+D`. `` is supported out of the box. Follow the following instruction to add support for keys other than ``. Merge the following json to your `keybindings.json`. + ```json { "key": "ctrl+x", @@ -249,6 +249,7 @@ Merge the following json to your `keybindings.json`. "when": "whichkeyActive" } ``` + Once you've done that, you can use `C-x` in the `key` value of the which-key config. Effectively, the above keybinding will enter `C-x` in the QuickPick input box when `ctrl+x` is pressed when the which key is focused. ### Display menu with a delay @@ -259,12 +260,164 @@ You can set `whichkey.sortOrder` in `settings.json` to `alphabetically` to alway ### Unclear selection Selected text can be hard to see when which-key menu is active. This could be due to the `inactiveSelectionBackground` config of your current theme. You can selectively override that color in your `settings.json` like the following example. + ```json "workbench.colorCustomizations": { "editor.inactiveSelectionBackground": "color that works better", }, ``` +### Conditional bindings (experimental) +
+ Click to expand! + +> This is marked as experimental and the config is subject to change. + +This allows conditional execution of bindings. Currently, it only supports conditions on the `when` passed from shortcut and `languageId` of the active editor. + +- It reuses the similar structure to the `bindings` type. +- The property `key` in a binding item is reused to represent the condition. +- The condition can be thought of as a key-value pair serialized into a string. + +`languageId:javascript;when:sideBarVisible` is an example condition serialized into a string for the `key` that checks if the language id of the currently active editor is javascript and if the side bar is visible (see the [when](#when) section for more details). + +A concrete example of a binding with that condition is as follow: + +```json +{ + "whichkey.bindings": [ + { + "key": "m", + "name": "Major...", + "type": "conditional", + "bindings": [ + { + "key": "languageId:javascript;when:sideBarVisible", + "name": "Open file", + "type": "command", + "command": "workbench.action.files.openFileFolder" + }, + { + "key": "", + "name": "Buffers", + "type": "bindings", + "bindings": [ + { + "key": "b", + "name": "Show all buffers/editors", + "type": "command", + "command": "workbench.action.showAllEditors" + } + ] + }, + ] + } + ] +} +``` + +In this example, when `m` is pressed, it will find the first binding that matches the current condition. +If no configured key matches the current condition, a default item showing a buffer menu will be used. +Any item that has an invalid key will be used as default item. + +Therefore, in this example, if the language is javascript and the sidebar is visible, `m` will open +the file browser, otherwise it will show the "buffers" menu. + +#### Overrides + +This is again similar with the `bindings` type. + +For example, the following config will override the `m` binding completely: + +```json +{ + "whichkey.bindingOverrides": [ + { + "keys": "m", + "name": "Major", + "type": "conditional", + "bindings": [ + { + "key": "languageId:javascript", + "name": "Go to", + "type": "command", + "command": "workbench.action.gotoLine", + } + ] + } + ] +} +``` + +You can also choose to add or remove conditions to existing conditional bindings. +For example, the following will add a key of `languageId:javascript` to the conditional binding if `languageId:javascript` doesn't already exist. + +```json +{ + "whichkey.bindingOverrides": [ + { + "keys": ["m", "languageId:javascript"], + "name": "Go to", + "type": "command", + "command": "workbench.action.gotoLine", + } + ] +} +``` + +Negative `position` property can also be used to remove conditional bindings. + +#### when + +Since VSCode doesn't allow reading of the context of a json field, we cannot read the condition used in the `when` in shortcuts. +For this reason, you will need to repeat every `when` condition used in conditional bindings, at least until [vscode/#10471](https://github.com/microsoft/vscode/issues/10471) is implemented. + +For example, the following shortcut in `keybindings.json` will pass both `key` and `when` in the `args` to `which-key`. The outer `when` is the [condition clause](https://code.visualstudio.com/docs/getstarted/keybindings#_when-clause-contexts) for vscode to execute this key, and must contain `whichKeyVisible` which limits this shortcut to be only applicable when the which-key menu is visible. In this case, if a user presses key `t` when which-key, sidebar and explorer viewlet are visible, it will execute `whichkey.triggerKey` command and send the `args` (`key` and `when`) to `which-key` + +```json +{ + "key": "t", + "command": "whichkey.triggerKey", + "args": { + "key": "t", + "when": "sideBarVisible && explorerViewletVisible" + }, + "when": "whichkeyVisible && sideBarVisible && explorerViewletVisible" +} +``` + +The `args.key` and `args.when` that were sent to `which-key` are then used to find the a binding that matches the key `t` and any conditional binding that matches that condition. The following binding is an example that contains a conditional binding that matches the `args`. + +```json +{ + "key": "t", + "name": "Show tree/explorer view", + "type": "conditional", + "bindings": [ + { + "key": "", + "name": "default", + "type": "command", + "command": "workbench.view.explorer" + }, + { + "key": "when:sideBarVisible && explorerViewletVisible", + "name": "Hide explorer", + "type": "command", + "command": "workbench.action.toggleSidebarVisibility" + } + ] +} +``` + +Unfortunately, if you have another condition binding with a different `key` that want to match the same `when` condition as the `t` in the above example, you will need to setup another shortcut with that different `key`. + +#### languageId + +This is language id of the active editor. The language id can be found in language selection menu inside the parenthesis next to the language name. + +
+ ## Release Notes See [CHANGELOG.md](CHANGELOG.md) diff --git a/package.json b/package.json index 38a601d..02d6eaa 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "key": "tab", "command": "whichkey.triggerKey", "args": "\t", - "when": "whichkeyActive" + "when": "whichkeyVisible" } ], "commands": [ diff --git a/src/bindingItem.ts b/src/bindingItem.ts index 227b7e8..fc3a0fb 100644 --- a/src/bindingItem.ts +++ b/src/bindingItem.ts @@ -3,6 +3,7 @@ export const enum ActionType { Commands = "commands", Bindings = "bindings", Transient = "transient", + Conditional = "conditional", } export interface BindingItem { diff --git a/src/constants.ts b/src/constants.ts index b3bb12a..ce74c24 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,8 +20,11 @@ export enum SortOrder { } export enum ContextKey { - Active = 'whichkeyActive' + Active = 'whichkeyActive', + Visible = 'whichkeyVisible' } export const whichKeyShow = `${contributePrefix}.${CommandKey.Show}`; export const whichKeyRegister = `${contributePrefix}.${CommandKey.Register}`; export const whichKeyTrigger = `${contributePrefix}.${CommandKey.Trigger}`; + +export const defaultStatusBarTimeout = 5000; \ No newline at end of file diff --git a/src/keyListener.ts b/src/keyListener.ts index b912e2e..29503c7 100644 --- a/src/keyListener.ts +++ b/src/keyListener.ts @@ -1,12 +1,20 @@ import { EventEmitter } from "vscode"; +export interface KeybindingArgs { + key: string, + when?: string, +} + export default class KeyListener { - emitter: EventEmitter; + emitter: EventEmitter; constructor() { - this.emitter = new EventEmitter(); + this.emitter = new EventEmitter(); } - trigger(key: string) { + trigger(key: string | KeybindingArgs) { + if (typeof key === "string") { + key = { key }; + } this.emitter.fire(key); } diff --git a/src/menu/menu.ts b/src/menu/menu.ts index b3d839c..91f4d8b 100644 --- a/src/menu/menu.ts +++ b/src/menu/menu.ts @@ -1,19 +1,19 @@ import { commands, Disposable, QuickPick, window } from "vscode"; -import { ActionType } from "../bindingItem"; -import KeyListener from "../keyListener"; +import { ContextKey, defaultStatusBarTimeout } from "../constants"; +import KeyListener, { KeybindingArgs } from "../keyListener"; import { setStatusBarMessage } from "../statusBar"; -import MenuItem, { convertToMenuLabel } from "./menuItem"; +import { BaseMenuItem, convertToMenuLabel } from "./menuItem"; export class WhichKeyMenu { private keyListener: KeyListener; - private items: MenuItem[]; + private items: BaseMenuItem[]; private title?: string; private isTransient: boolean; - private quickPick: QuickPick; + private quickPick: QuickPick; private disposables: Disposable[]; private isHiding: boolean; - private itemHistory: MenuItem[]; + private itemHistory: BaseMenuItem[]; // Promise related properties for the promise returned by show() private promise: Promise; @@ -26,34 +26,45 @@ export class WhichKeyMenu { // This is the currently entered value in delay mode // so we can display the chain of keys that's been entered private enteredValue = ''; + // This used to stored the last when condition from the key listener + private when?: string; - constructor(keyListener: KeyListener, items: MenuItem[], isTransient: boolean, delay: number, title?: string) { + constructor(keyListener: KeyListener, items: BaseMenuItem[], isTransient: boolean, delay: number, title?: string) { this.keyListener = keyListener; this.items = items; this.isTransient = isTransient; this.delay = delay; this.title = title; - this.quickPick = window.createQuickPick(); + this.quickPick = window.createQuickPick(); this.promise = new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }); this.disposables = [ - this.keyListener.onDidKeyPressed(this.onDidKeyPressed.bind(this)), - this.quickPick.onDidChangeValue(this.onDidChangeValue.bind(this)), - this.quickPick.onDidAccept(this.onDidAccept.bind(this)), - this.quickPick.onDidHide(this.onDidHide.bind(this)) + this.keyListener.onDidKeyPressed(this.onDidKeyPressed, this), + this.quickPick.onDidChangeValue(this.onDidChangeValue, this), + this.quickPick.onDidAccept(this.onDidAccept, this), + this.quickPick.onDidHide(this.onDidHide, this) ]; this.isHiding = false; this.itemHistory = []; } - private onDidKeyPressed(value: string) { - this.quickPick.value += value; - this.onDidChangeValue(this.quickPick.value); + private get condition() { + const languageId = window.activeTextEditor?.document.languageId; + return { + when: this.when, + languageId + }; + } + + private onDidKeyPressed(value: KeybindingArgs) { + this.quickPick.value += value.key; + this.onDidChangeValue(this.quickPick.value, value.when); } - private async onDidChangeValue(value: string) { + private async onDidChangeValue(value: string, when?: string) { + this.when = when; if (this.timeoutId) { // When the menu is in the delay display mode if (value.startsWith(this.enteredValue)) { @@ -74,7 +85,7 @@ export class WhichKeyMenu { } else { await this.hide(); const keyCombo = this.getHistoryString(value); - setStatusBarMessage(`${keyCombo} is undefined`, 5000, true); + setStatusBarMessage(`${keyCombo} is undefined`, defaultStatusBarTimeout, true); this.dispose(); this.resolve(); } @@ -88,20 +99,21 @@ export class WhichKeyMenu { return keyCombo.map(convertToMenuLabel).join(' '); } - private onDidAccept() { + private async onDidAccept() { if (this.quickPick.activeItems.length > 0) { - const chosenItems = this.quickPick.activeItems[0] as MenuItem; - this.select(chosenItems); + const chosenItems = this.quickPick.activeItems[0] as BaseMenuItem; + await this.select(chosenItems); } } - private onDidHide() { + private async onDidHide() { this.clearDelay(); if (!this.isHiding) { // Dispose correctly when it is not manually hiding this.dispose(); this.resolve(); } + await setContext(ContextKey.Visible, false); } // Manually hide the menu @@ -117,7 +129,7 @@ export class WhichKeyMenu { }); } - private async select(item: MenuItem) { + private async select(item: BaseMenuItem) { try { await ((this.isTransient) ? this.selectActionTransient(item) @@ -128,67 +140,41 @@ export class WhichKeyMenu { } } - private async selectAction(item: MenuItem) { - if (item.type === ActionType.Command && item.command) { - await this.hide(); - await executeCommand(item.command, item.args); - this.dispose(); - this.resolve(); - } else if (item.type === ActionType.Commands && item.commands) { - await this.hide(); - await executeCommands(item.commands, item.args); - this.dispose(); - this.resolve(); - } else if (item.type === ActionType.Bindings && item.items) { - this.updateState(item.items, false, item.name); - this.itemHistory.push(item); - this.show(); - } else if (item.type === ActionType.Transient && item.items) { + private async selectAction(item: BaseMenuItem) { + const result = item.select(this.condition); + if (result.commands) { await this.hide(); - // optionally execute command/s before transient - if (item.commands) { - await executeCommands(item.commands, item.args); - } else if (item.command) { - await executeCommand(item.command, item.args); - } - this.updateState(item.items, true, item.name); + await executeCommands(result.commands, result.args); + } + + if (result.items) { + this.updateState(result.items, !!result.isTransient, item.name); this.itemHistory.push(item); - this.show(); + await this.show(); } else { - const keyCombo = this.getHistoryString(item.key); - throw new ActionError(item.type, keyCombo); + this.dispose(); + this.resolve(); } } - private async selectActionTransient(item: MenuItem) { + private async selectActionTransient(item: BaseMenuItem) { await this.hide(); - if (item.type === ActionType.Command && item.command) { - await executeCommand(item.command, item.args); - } else if (item.type === ActionType.Commands && item.commands) { - await executeCommands(item.commands, item.args); - } else if (item.type === ActionType.Bindings && item.items) { - this.updateState(item.items, false, item.name); - this.itemHistory.push(item); - } else if (item.type === ActionType.Transient && item.items) { - // optionally execute command/s before transient - if (item.commands) { - await executeCommands(item.commands, item.args); - } else if (item.command) { - await executeCommand(item.command, item.args); - } - this.updateState(item.items, true, item.name); + const result = item.select(this.condition); + if (result.commands) { + await executeCommands(result.commands, result.args); + } + + if (result.items) { + this.updateState(result.items, !!result.isTransient, item.name); this.itemHistory.push(item); - } else { - const keyCombo = this.getHistoryString(item.key); - throw new ActionError(item.type, keyCombo); } - this.show(); + await this.show(); } - private updateState(items: MenuItem[], isTransient: boolean, title?: string) { + private updateState(items: BaseMenuItem[], isTransient: boolean, title?: string) { this.items = items; this.isTransient = isTransient; this.title = title; @@ -201,7 +187,7 @@ export class WhichKeyMenu { } } - private show() { + private async show() { const updateQuickPick = () => { this.quickPick.busy = false; this.enteredValue = ''; @@ -223,6 +209,7 @@ export class WhichKeyMenu { updateQuickPick(); } + await setContext(ContextKey.Visible, true); this.quickPick.show(); } @@ -234,13 +221,22 @@ export class WhichKeyMenu { this.quickPick.dispose(); } - static show(keyListener: KeyListener, items: MenuItem[], isTransient: boolean, delay: number, title?: string) { - const menu = new WhichKeyMenu(keyListener, items, isTransient, delay, title); - menu.show(); - return menu.promise; + static async show(keyListener: KeyListener, items: BaseMenuItem[], isTransient: boolean, delay: number, title?: string) { + try { + const menu = new WhichKeyMenu(keyListener, items, isTransient, delay, title); + await setContext(ContextKey.Active, true); + await menu.show(); + return await menu.promise; + } finally { + await setContext(ContextKey.Active, false); + } } } +function setContext(key: string, value: any) { + return commands.executeCommand("setContext", key, value); +} + function executeCommand(cmd: string, args: any) { if (Array.isArray(args)) { const arr = args as any[]; @@ -260,10 +256,4 @@ async function executeCommands(cmds: string[], args: any) { const arg = args?.[i]; await executeCommand(cmd, arg); } -} - -class ActionError extends Error { - constructor(itemType: string, keyCombo: string) { - super(`Incorrect properties for ${itemType} type with the key combination of ${keyCombo}`); - } } \ No newline at end of file diff --git a/src/menu/menuItem.ts b/src/menu/menuItem.ts index c730494..e450732 100644 --- a/src/menu/menuItem.ts +++ b/src/menu/menuItem.ts @@ -1,37 +1,26 @@ -import { QuickPickItem } from 'vscode'; +import { QuickPickItem } from "vscode"; import { ActionType, BindingItem, OverrideBindingItem } from "../bindingItem"; -import { SortOrder } from '../constants'; +import { defaultStatusBarTimeout, SortOrder } from "../constants"; +import { setStatusBarMessage } from "../statusBar"; -export default class MenuItem implements QuickPickItem { - name: string; +interface Condition { + when?: string, + languageId?: string, +} + +export interface MenuSelectionResult { + items?: BaseMenuItem[], + isTransient?: boolean, + commands?: string[], + args?: any, +} + +export abstract class BaseMenuItem implements QuickPickItem { key: string; - type: ActionType; - command?: string; - commands?: string[]; - items?: MenuItem[]; - args?: any; - - private constructor(item: { - name: string, - key: string, - type: ActionType, - bindings?: BindingItem[], - command?: string, - commands?: string[], - items?: MenuItem[], - args?: any, - }) { - this.name = item.name; - this.key = item.key; - this.type = item.type; - this.command = item.command; - this.commands = item.commands; - this.args = item.args; - if (this.type === ActionType.Bindings && item.bindings) { - this.items = MenuItem.createMenuItems(item.bindings); - } else if (this.type === ActionType.Transient && item.bindings) { - this.items = MenuItem.createMenuItems(item.bindings); - } + name: string; + constructor(key: string, name: string) { + this.key = key; + this.name = name; } get label() { @@ -43,110 +32,352 @@ export default class MenuItem implements QuickPickItem { return `\t${this.name}`; } - static createMenuItems(bindingItems: BindingItem[]) { - return bindingItems.map(i => new MenuItem(i)); + abstract select(args?: Condition): MenuSelectionResult; + sortItems(_order: SortOrder): void { + // Nothing to sort + } + + get(_key: string): BaseMenuItem | undefined { + return undefined; + } + + overrideItem(_overrides: OverrideBindingItem) { + // Nothing to override by default + } +} + +abstract class BaseCollectionMenuItem extends BaseMenuItem { + protected items: BaseMenuItem[]; + + constructor(key: string, name: string, items: BaseMenuItem[]) { + super(key, name); + this.items = items; + } + + sortItems(order: SortOrder): void { + sortMenuItems(this.items, order); + for (const item of this.items) { + item.sortItems(order); + } + } + + get(key: string) { + return this.items.find(i => i.key === key); + } + + protected getIndex(key: string) { + return this.items.findIndex(i => i.key === key); + } + + overrideItem(o: OverrideBindingItem) { + const keys = (typeof o.keys === 'string') ? o.keys.split('.') : o.keys; + const key = keys[keys.length - 1]; + const index = this.getIndex(key); + + if (o.position === undefined) { + const newItem = createMenuItemFromOverrides(o, key); + if (index !== -1) { + // replace the current item + this.items.splice(index, 1, newItem); + } else { + // append if there isn't an existing binding + this.items.push(newItem); + } + } else { + if (o.position < 0) { + // negative position, attempt to remove + if (index !== -1) { + this.items.splice(index, 1); + } + } else { + // Remove and replace + if (index !== -1) { + this.items.splice(index, 1); + } + const newItem = createMenuItemFromOverrides(o, key); + this.items.splice(o.position, 0, newItem); + } + } + } +} + +class BindingsMenuItem extends BaseCollectionMenuItem { + constructor(item: BindingItem) { + if (!item.bindings) { + throw new MissingPropertyError("bindings", ActionType.Bindings); + } + const items = createMenuItems(item.bindings); + super(item.key, item.name, items); + } + + select(_args?: Condition): MenuSelectionResult { + return { + items: this.items, + isTransient: false, + }; + } +} + +class CommandsMenuItem extends BaseMenuItem { + private commands: string[]; + private args?: any; + + constructor(item: BindingItem) { + if (!item.commands) { + throw new MissingPropertyError("commands", ActionType.Commands); + } + + super(item.key, item.name); + this.commands = item.commands; + this.args = item.args; + } + + select(_args?: Condition): MenuSelectionResult { + return { + commands: this.commands, + args: this.args + }; + } +} + +function evalCondition(stored?: Condition, evaluatee?: Condition) { + if (evaluatee && stored) { + let result = true; + if (stored.when) { + result = result && (stored.when === evaluatee.when); + } + if (stored.languageId) { + result = result && (stored.languageId === evaluatee.languageId); + } + return result; + } + // For if they are both undefined or null + return stored === evaluatee; +} + +function isConditionEqual(condition1?: Condition, condition2?: Condition) { + if (condition1 && condition2) { + let result = true; + result = result && (condition1.when === condition2.when); + result = result && (condition1.languageId === condition2.languageId); + return result; + } + // For if they are both undefined or null + return condition1 === condition2; +} + +function isConditionKeyEqual(key1?: string, key2?: string) { + return isConditionEqual(getCondition(key1), getCondition(key2)); +} + +function getCondition(key?: string): Condition | undefined { + if (key && key.length > 0) { + const props = key.split(";"); + const r = props.reduce((result, prop) => { + const [key, value] = prop.split(":"); + result[key] = value; + return result; + }, {} as Record); + + // Check to make sure at least the one property so we don't create + // { when: undefined, languagedId: undefined } + if ("when" in r || "languageId" in r) { + return { + when: r["when"], + languageId: r["languageId"] + }; + } + } + return undefined; +} + +class ConditionalsMenuItem extends BaseCollectionMenuItem { + constructor(item: BindingItem) { + if (!item.bindings) { + throw new MissingPropertyError("bindings", ActionType.Conditional); + } + const items = createMenuItems(item.bindings); + super(item.key, item.name, items); + } + + get(key: string) { + return this.items.find(i => isConditionKeyEqual(key, i.key)); + } + + protected getIndex(key: string) { + return this.items.findIndex(i => isConditionKeyEqual(key, i.key)); + } + + eval(condition?: Condition) { + return this.items.find(i => evalCondition(getCondition(i.key), condition)); + } + + select(args?: Condition): MenuSelectionResult { + // Search the condition first. If no matches, find the first empty condition as else + let match = this.eval(args) ?? this.eval(undefined); + if (match) { + return match.select(args); + } + + const msg = "No conditions matched"; + console.warn(`${msg};key=${this.key};name=${this.name}`); + setStatusBarMessage(msg, defaultStatusBarTimeout, true); + return {}; + } +} + +class CommandMenuItem extends BaseMenuItem { + private command: string; + private args?: any; + + constructor(item: BindingItem) { + if (!item.command) { + throw new MissingPropertyError("command", ActionType.Command); + } + + super(item.key, item.name); + this.command = item.command; + this.args = item.args; + } + + select(_args?: Condition): MenuSelectionResult { + return { + commands: [this.command], + args: this.args ? [this.args] : this.args + }; + } +} + +class TransientMenuItem extends BaseCollectionMenuItem { + private commands?: string[]; + private args?: any; + + constructor(item: BindingItem) { + if (!item.bindings) { + throw new MissingPropertyError("bindings", ActionType.Transient); + } + const items = createMenuItems(item.bindings); + + super(item.key, item.name, items); + if (item.commands) { + this.commands = item.commands; + this.args = item.args; + } else if (item.command) { + this.commands = [item.command]; + this.args = [item.args]; + } + } + + select(_args?: Condition): MenuSelectionResult { + return { + items: this.items, + isTransient: true, + commands: this.commands, + args: this.args + }; + } +} + +export class RootMenuItem extends BaseCollectionMenuItem { + constructor(bindings: BindingItem[]) { + const items = createMenuItems(bindings); + super("", "", items); + } + + select(_args?: Condition): MenuSelectionResult { + return { + items: this.items, + isTransient: false, + }; } - static overrideMenuItems( - items?: MenuItem[], - overrides?: OverrideBindingItem[]) { - overrides?.forEach(o => { + override(overrides: OverrideBindingItem[]) { + for (const o of overrides) { try { const keys = (typeof o.keys === 'string') ? o.keys.split('.') : o.keys; - - // Traverse to the last level - let menuItems = items; + let menuItem: BaseMenuItem | undefined = this; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; - const keyIndex = menuItems?.findIndex(i => i.key === key); - if (keyIndex === undefined || keyIndex === -1) { + menuItem = menuItem?.get(key); + if (menuItem === undefined) { console.warn(`Key ${key} of ${o.keys} not found`); break; } - menuItems = menuItems?.[keyIndex]?.items; - } - - if (menuItems !== undefined) { - const key = keys[keys.length - 1]; - const index = menuItems.findIndex(i => i.key === key); - - if (o.position === undefined) { - const newItem = MenuItem.createFromOverride(o, key); - if (index !== -1) { - // replace the current item - menuItems.splice(index, 1, newItem); - } else { - // append if there isn't an existing binding - menuItems.push(newItem); - } - } else { - if (o.position < 0) { - // negative position, attempt to remove - if (index !== -1) { - menuItems.splice(index, 1); - } - } else { - // Remove and replace - if (index !== -1) { - menuItems.splice(index, 1); - } - const newItem = MenuItem.createFromOverride(o, key); - menuItems.splice(o.position, 0, newItem); - } - } } + menuItem?.overrideItem(o); } catch (e) { console.error(e); } - }); - } - - static sortMenuItems(items: MenuItem[] | undefined, order: SortOrder) { - if (items && order !== SortOrder.None) { - - if (order === SortOrder.Alphabetically) { - items.sort((a, b) => a.key.localeCompare(b.key)); - } else if (order === SortOrder.NonNumberFirst) { - items.sort((a, b) => { - const regex = /^[0-9]/; - const aStartsWithNumber = regex.test(a.key); - const bStartsWithNumber = regex.test(b.key); - if (aStartsWithNumber !== bStartsWithNumber) { - // Sort non-number first - return aStartsWithNumber ? 1 : -1; - } else { - return a.key.localeCompare(b.key); - } - }); - } - - for (const item of items) { - MenuItem.sortMenuItems(item.items, order); - } } } +} - static createFromBinding(item: BindingItem) { - return new MenuItem(item); +function createMenuItem(bindingItem: BindingItem): BaseMenuItem { + switch (bindingItem.type) { + case ActionType.Command: + return new CommandMenuItem(bindingItem); + case ActionType.Commands: + return new CommandsMenuItem(bindingItem); + case ActionType.Bindings: + return new BindingsMenuItem(bindingItem); + case ActionType.Transient: + return new TransientMenuItem(bindingItem); + case ActionType.Conditional: + return new ConditionalsMenuItem(bindingItem); + default: + throw new Error(`type ${bindingItem.type} is not supported`); } +} - static createFromOverride(o: OverrideBindingItem, key: string) { - if (o.name !== undefined && o.type !== undefined) { - return new MenuItem({ - key: key, - name: o.name, - type: o.type, - command: o.command, - commands: o.commands, - args: o.args, - bindings: o.bindings, +// sort menu item in-place +function sortMenuItems(items: BaseMenuItem[], order: SortOrder) { + if (order !== SortOrder.None) { + if (order === SortOrder.Alphabetically) { + items.sort((a, b) => a.key.localeCompare(b.key)); + } else if (order === SortOrder.NonNumberFirst) { + items.sort((a, b) => { + const regex = /^[0-9]/; + const aStartsWithNumber = regex.test(a.key); + const bStartsWithNumber = regex.test(b.key); + if (aStartsWithNumber !== bStartsWithNumber) { + // Sort non-number first + return aStartsWithNumber ? 1 : -1; + } else { + return a.key.localeCompare(b.key); + } }); - } else { - throw new Error('name or type of the override is undefined'); } } } +function createMenuItemFromOverrides(o: OverrideBindingItem, key: string) { + if (o.name !== undefined && o.type !== undefined) { + return createMenuItem({ + key: key, + name: o.name, + type: o.type, + command: o.command, + commands: o.commands, + args: o.args, + bindings: o.bindings, + }); + } else { + throw new Error('name or type of the override is undefined'); + } +} + +function createMenuItems(bindingItems: BindingItem[]) { + return bindingItems.map(createMenuItem); +} + export function convertToMenuLabel(s: string) { return s.replace(/ /g, '␣').replace(/\t/g, '↹'); +} + +class MissingPropertyError extends Error { + name: string; + constructor(propertyName: string, typeName: string) { + super(); + this.name = `Property ${propertyName} is not defined for type ${typeName}`; + } } \ No newline at end of file diff --git a/src/whichKeyCommand.ts b/src/whichKeyCommand.ts index 3df1efc..0c78741 100644 --- a/src/whichKeyCommand.ts +++ b/src/whichKeyCommand.ts @@ -1,14 +1,14 @@ -import { commands, Disposable, workspace } from "vscode"; +import { Disposable, workspace } from "vscode"; import { BindingItem, OverrideBindingItem } from "./bindingItem"; -import { ConfigKey, ContextKey, contributePrefix, SortOrder } from "./constants"; +import { ConfigKey, contributePrefix, SortOrder } from "./constants"; import KeyListener from "./keyListener"; import { WhichKeyMenu } from "./menu/menu"; -import MenuItem from "./menu/menuItem"; -import { WhichKeyConfig } from "./whichKeyConfig"; +import { BaseMenuItem, RootMenuItem } from "./menu/menuItem"; +import { getFullSection, WhichKeyConfig } from "./whichKeyConfig"; export default class WhichKeyCommand { private keyListener: KeyListener; - private items?: MenuItem[]; + private root?: RootMenuItem; private config?: WhichKeyConfig; private onConfigChangeListener?: Disposable; constructor(keyListener: KeyListener) { @@ -23,28 +23,26 @@ export default class WhichKeyCommand { .getConfiguration(config.bindings[0]) .get(config.bindings[1]); if (bindings) { - this.items = MenuItem.createMenuItems(bindings); - } else { - this.items = undefined; + this.root = new RootMenuItem(bindings); } if (config.overrides) { const overrides = workspace .getConfiguration(config.overrides[0]) - .get(config.overrides[1]); - MenuItem.overrideMenuItems(this.items, overrides); + .get(config.overrides[1]) ?? []; + this.root?.override(overrides); } const sortOrder = workspace .getConfiguration(contributePrefix) .get(ConfigKey.SortOrder) ?? SortOrder.None; - MenuItem.sortMenuItems(this.items, sortOrder); + this.root?.sortItems(sortOrder); this.onConfigChangeListener = workspace.onDidChangeConfiguration((e) => { if ( - e.affectsConfiguration(`${contributePrefix}.${ConfigKey.SortOrder}`) || - e.affectsConfiguration(`${config.bindings[0]}.${config.bindings[1]}`) || - (config.overrides && e.affectsConfiguration(`${config.overrides[0]}.${config.overrides[1]}`)) + e.affectsConfiguration(getFullSection([contributePrefix, ConfigKey.SortOrder])) || + e.affectsConfiguration(getFullSection(config.bindings)) || + (config.overrides && e.affectsConfiguration(getFullSection(config.overrides))) ) { this.register(config); } @@ -52,35 +50,26 @@ export default class WhichKeyCommand { } unregister() { - this.items = undefined; + this.root = undefined; this.onConfigChangeListener?.dispose(); } show() { - if (this.items) { - return showMenu(this.keyListener, this.items, false, this.config?.title); + const items = this.root?.select().items; + if (items) { + return showMenu(this.keyListener, items, false, this.config?.title); } else { - throw new Error("No bindings is available"); + throw new Error("No bindings are available"); } } static show(bindings: BindingItem[], keyWatcher: KeyListener) { - const items = MenuItem.createMenuItems(bindings); + const items = new RootMenuItem(bindings).select().items!; return showMenu(keyWatcher, items, false); } } -function setContext(key: string, value: any) { - return commands.executeCommand("setContext", key, value); -} - - -async function showMenu(keyListener: KeyListener, items: MenuItem[], isTransient: boolean, title?: string) { - try { - const delay = workspace.getConfiguration(contributePrefix).get(ConfigKey.Delay) ?? 0; - await setContext(ContextKey.Active, true); - await WhichKeyMenu.show(keyListener, items, isTransient, delay, title); - } finally { - await setContext(ContextKey.Active, false); - } +function showMenu(keyListener: KeyListener, items: BaseMenuItem[], isTransient: boolean, title?: string) { + const delay = workspace.getConfiguration(contributePrefix).get(ConfigKey.Delay) ?? 0; + return WhichKeyMenu.show(keyListener, items, isTransient, delay, title); } \ No newline at end of file diff --git a/src/whichKeyConfig.ts b/src/whichKeyConfig.ts index 52ba6ed..10933cd 100644 --- a/src/whichKeyConfig.ts +++ b/src/whichKeyConfig.ts @@ -1,11 +1,14 @@ import { ConfigKey, contributePrefix } from "./constants"; +type ConfigSections = [string, string]; + export interface WhichKeyConfig { - bindings: [string, string], - overrides?: [string, string], + bindings: ConfigSections, + overrides?: ConfigSections, title?: string, } + export function toWhichKeyConfig(o: any) { if (typeof o === "object") { const config = o as Partial; @@ -19,7 +22,7 @@ export function toWhichKeyConfig(o: any) { return undefined; } -export function getFullSection(sections: [string, string]) { +export function getFullSection(sections: ConfigSections) { return `${sections[0]}.${sections[1]}`; }