Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for custom menu node registration. #8404

Merged
merged 2 commits into from
Aug 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/api-samples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
{
"frontend": "lib/browser/api-samples-frontend-module"
},
{
"frontend": "lib/browser/menu/sample-browser-menu-module",
"frontendElectron": "lib/electron-browser/menu/sample-electron-menu-module"
},
{
"electronMain": "lib/electron-main/update/sample-updater-main-module",
"frontendElectron": "lib/electron-browser/updater/sample-updater-frontend-module"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/********************************************************************************
* Copyright (C) 2020 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { injectable, ContainerModule } from 'inversify';
import { Menu as MenuWidget } from '@phosphor/widgets';
import { Disposable } from '@theia/core/lib/common/disposable';
import { MenuNode, CompositeMenuNode } from '@theia/core/lib/common/menu';
import { BrowserMainMenuFactory, MenuCommandRegistry, DynamicMenuWidget } from '@theia/core/lib/browser/menu/browser-menu-plugin';
import { PlaceholderMenuNode } from './sample-menu-contribution';

export default new ContainerModule((bind, unbind, isBound, rebind) => {
rebind(BrowserMainMenuFactory).to(SampleBrowserMainMenuFactory).inSingletonScope();
});

@injectable()
class SampleBrowserMainMenuFactory extends BrowserMainMenuFactory {

protected handleDefault(menuCommandRegistry: MenuCommandRegistry, menuNode: MenuNode): void {
if (menuNode instanceof PlaceholderMenuNode && menuCommandRegistry instanceof SampleMenuCommandRegistry) {
menuCommandRegistry.registerPlaceholderMenu(menuNode);
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected createMenuCommandRegistry(menu: CompositeMenuNode, args: any[] = []): MenuCommandRegistry {
const menuCommandRegistry = new SampleMenuCommandRegistry(this.services);
this.registerMenu(menuCommandRegistry, menu, args);
return menuCommandRegistry;
}

createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry }): DynamicMenuWidget {
return new SampleDynamicMenuWidget(menu, options, this.services);
}

}

class SampleMenuCommandRegistry extends MenuCommandRegistry {

protected placeholders = new Map<string, PlaceholderMenuNode>();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerPlaceholderMenu(menu: PlaceholderMenuNode): void {
const { id } = menu;
if (this.placeholders.has(id)) {
return;
}
this.placeholders.set(id, menu);
}

snapshot(): this {
super.snapshot();
for (const menu of this.placeholders.values()) {
this.toDispose.push(this.registerPlaceholder(menu));
}
return this;
}

protected registerPlaceholder(menu: PlaceholderMenuNode): Disposable {
const { id } = menu;
const unregisterCommand = this.addCommand(id, {
execute: () => { /* NOOP */ },
label: menu.label,
icon: menu.icon,
isEnabled: () => false,
isVisible: () => true
});
return Disposable.create(() => unregisterCommand.dispose());
}

}

class SampleDynamicMenuWidget extends DynamicMenuWidget {

protected handleDefault(menuNode: MenuNode): MenuWidget.IItemOptions[] {
if (menuNode instanceof PlaceholderMenuNode) {
return [{
command: menuNode.id,
type: 'command'
}];
}
return [];
}

}
28 changes: 24 additions & 4 deletions examples/api-samples/src/browser/menu/sample-menu-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { Command, CommandContribution, CommandRegistry, MAIN_MENU_BAR, MenuContribution, MenuModelRegistry } from '@theia/core/lib/common';
import { Command, CommandContribution, CommandRegistry, MAIN_MENU_BAR, MenuContribution, MenuModelRegistry, MenuNode, SubMenuOptions } from '@theia/core/lib/common';
import { injectable, interfaces } from 'inversify';

const SampleCommand: Command = {
Expand Down Expand Up @@ -59,16 +59,36 @@ export class SampleMenuContribution implements MenuContribution {
order: '2'
});
const subSubMenuPath = [...subMenuPath, 'sample-sub-menu'];
menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', { order: '1' });
menus.registerSubmenu(subSubMenuPath, 'Sample sub menu', { order: '2' });
menus.registerMenuAction(subSubMenuPath, {
commandId: SampleCommand.id,
order: '0'
order: '1'
});
menus.registerMenuAction(subSubMenuPath, {
commandId: SampleCommand2.id,
order: '2'
order: '3'
});
const placeholder = new PlaceholderMenuNode([...subSubMenuPath, 'placeholder'].join('-'), 'Placeholder', { order: '0' });
menus.registerMenuNode(subSubMenuPath, placeholder);
}

}

/**
* Special menu node that is not backed by any commands and is always disabled.
*/
export class PlaceholderMenuNode implements MenuNode {

constructor(readonly id: string, public readonly label: string, protected options?: SubMenuOptions) { }

get icon(): string | undefined {
return this.options?.iconClass;
}

get sortString(): string {
return this.options?.order || this.label;
}

}

export const bindSampleMenu = (bind: interfaces.Bind) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/********************************************************************************
* Copyright (C) 2020 TypeFox and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { injectable, ContainerModule } from 'inversify';
import { CompositeMenuNode } from '@theia/core/lib/common/menu';
import { ElectronMainMenuFactory, ElectronMenuOptions } from '@theia/core/lib/electron-browser/menu/electron-main-menu-factory';
import { PlaceholderMenuNode } from '../../browser/menu/sample-menu-contribution';

export default new ContainerModule((bind, unbind, isBound, rebind) => {
rebind(ElectronMainMenuFactory).to(SampleElectronMainMenuFactory).inSingletonScope();
});

@injectable()
class SampleElectronMainMenuFactory extends ElectronMainMenuFactory {

// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected handleDefault(menuNode: CompositeMenuNode, args: any[] = [], options?: ElectronMenuOptions): Electron.MenuItemConstructorOptions[] {
if (menuNode instanceof PlaceholderMenuNode) {
return [{
label: menuNode.label,
enabled: false,
visible: true
}];
}
return [];
}

}
45 changes: 34 additions & 11 deletions packages/core/src/browser/menu/browser-menu-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { MenuBar, Menu as MenuWidget, Widget } from '@phosphor/widgets';
import { CommandRegistry as PhosphorCommandRegistry } from '@phosphor/commands';
import {
CommandRegistry, ActionMenuNode, CompositeMenuNode,
MenuModelRegistry, MAIN_MENU_BAR, MenuPath, DisposableCollection, Disposable
MenuModelRegistry, MAIN_MENU_BAR, MenuPath, DisposableCollection, Disposable, MenuNode
} from '../../common';
import { KeybindingRegistry } from '../keybinding';
import { FrontendApplicationContribution, FrontendApplication } from '../frontend-application';
Expand All @@ -34,7 +34,7 @@ export abstract class MenuBarWidget extends MenuBar {
}

@injectable()
export class BrowserMainMenuFactory {
export class BrowserMainMenuFactory implements MenuWidgetFactory {

@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;
Expand Down Expand Up @@ -68,7 +68,7 @@ export class BrowserMainMenuFactory {
const menuCommandRegistry = this.createMenuCommandRegistry(menuModel);
for (const menu of menuModel.children) {
if (menu instanceof CompositeMenuNode) {
const menuWidget = new DynamicMenuWidget(menu, { commands: menuCommandRegistry }, this.services);
const menuWidget = this.createMenuWidget(menu, { commands: menuCommandRegistry });
menuBar.addMenu(menuWidget);
}
}
Expand All @@ -78,10 +78,14 @@ export class BrowserMainMenuFactory {
createContextMenu(path: MenuPath, args?: any[]): MenuWidget {
const menuModel = this.menuProvider.getMenu(path);
const menuCommandRegistry = this.createMenuCommandRegistry(menuModel, args).snapshot();
const contextMenu = new DynamicMenuWidget(menuModel, { commands: menuCommandRegistry }, this.services);
const contextMenu = this.createMenuWidget(menuModel, { commands: menuCommandRegistry });
return contextMenu;
}

createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry }): DynamicMenuWidget {
return new DynamicMenuWidget(menu, options, this.services);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected createMenuCommandRegistry(menu: CompositeMenuNode, args: any[] = []): MenuCommandRegistry {
const menuCommandRegistry = new MenuCommandRegistry(this.services);
Expand All @@ -99,22 +103,30 @@ export class BrowserMainMenuFactory {
}
} else if (child instanceof CompositeMenuNode) {
this.registerMenu(menuCommandRegistry, child, args);
} else {
this.handleDefault(menuCommandRegistry, child, args);
}
}
}

private get services(): MenuServices {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected handleDefault(menuCommandRegistry: MenuCommandRegistry, menuNode: MenuNode, args: any[]): void {
// NOOP
}

protected get services(): MenuServices {
return {
context: this.context,
contextKeyService: this.contextKeyService,
commandRegistry: this.commandRegistry,
keybindingRegistry: this.keybindingRegistry
keybindingRegistry: this.keybindingRegistry,
menuWidgetFactory: this
};
}

}

class DynamicMenuBarWidget extends MenuBarWidget {
export class DynamicMenuBarWidget extends MenuBarWidget {

/**
* We want to restore the focus after the menu closes.
Expand Down Expand Up @@ -183,17 +195,22 @@ class DynamicMenuBarWidget extends MenuBarWidget {

}

class MenuServices {
export class MenuServices {
readonly commandRegistry: CommandRegistry;
readonly keybindingRegistry: KeybindingRegistry;
readonly contextKeyService: ContextKeyService;
readonly context: ContextMenuContext;
readonly menuWidgetFactory: MenuWidgetFactory;
}

export interface MenuWidgetFactory {
createMenuWidget(menu: CompositeMenuNode, options: MenuWidget.IOptions & { commands: MenuCommandRegistry }): MenuWidget;
}

/**
* A menu widget that would recompute its items on update.
*/
class DynamicMenuWidget extends MenuWidget {
export class DynamicMenuWidget extends MenuWidget {

/**
* We want to restore the focus after the menu closes.
Expand Down Expand Up @@ -247,7 +264,7 @@ class DynamicMenuWidget extends MenuWidget {
if (item instanceof CompositeMenuNode) {
if (item.children.length) { // do not render empty nodes
if (item.isSubmenu) { // submenu node
const submenu = new DynamicMenuWidget(item, this.options, this.services);
const submenu = this.services.menuWidgetFactory.createMenuWidget(item, this.options);
if (!submenu.items.length) {
continue;
}
Expand Down Expand Up @@ -279,11 +296,17 @@ class DynamicMenuWidget extends MenuWidget {
command: node.action.commandId,
type: 'command'
});
} else {
items.push(...this.handleDefault(item));
}
}
return items;
}

protected handleDefault(menuNode: MenuNode): MenuWidget.IItemOptions[] {
return [];
}

protected preserveFocusedElement(previousFocusedElement: Element | null = document.activeElement): boolean {
if (!this.previousFocusedElement && previousFocusedElement instanceof HTMLElement) {
this.previousFocusedElement = previousFocusedElement;
Expand Down Expand Up @@ -351,7 +374,7 @@ export class BrowserMenuBarContribution implements FrontendApplicationContributi
/**
* Stores Theia-specific action menu nodes instead of PhosphorJS commands with their handlers.
*/
class MenuCommandRegistry extends PhosphorCommandRegistry {
export class MenuCommandRegistry extends PhosphorCommandRegistry {

// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected actions = new Map<string, [ActionMenuNode, any[]]>();
Expand Down
17 changes: 14 additions & 3 deletions packages/core/src/common/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,13 @@ export class MenuModelRegistry {
}

registerMenuAction(menuPath: MenuPath, item: MenuAction): Disposable {
const menuNode = new ActionMenuNode(item, this.commands);
return this.registerMenuNode(menuPath, menuNode);
}

registerMenuNode(menuPath: MenuPath, menuNode: MenuNode): Disposable {
const parent = this.findGroup(menuPath);
const actionNode = new ActionMenuNode(item, this.commands);
return parent.addNode(actionNode);
return parent.addNode(menuNode);
}

registerSubmenu(menuPath: MenuPath, label: string, options?: SubMenuOptions): Disposable {
Expand Down Expand Up @@ -143,7 +147,14 @@ export class MenuModelRegistry {
return;
}

// Recurse all menus, removing any menus matching the id
this.unregisterMenuNode(id);
}

/**
* Recurse all menus, removing any menus matching the `id`.
* @param id technical identifier of the `MenuNode`.
*/
unregisterMenuNode(id: string): void {
const recurse = (root: CompositeMenuNode) => {
root.children.forEach(node => {
if (node instanceof CompositeMenuNode) {
Expand Down
Loading