diff --git a/v2/internal/app/desktop.go b/v2/internal/app/desktop.go index 79ea71a3e..58987b699 100644 --- a/v2/internal/app/desktop.go +++ b/v2/internal/app/desktop.go @@ -3,6 +3,9 @@ package app import ( + "fmt" + goruntime "runtime" + "github.com/wailsapp/wails/v2/internal/binding" "github.com/wailsapp/wails/v2/internal/ffenestri" "github.com/wailsapp/wails/v2/internal/logger" @@ -11,6 +14,7 @@ import ( "github.com/wailsapp/wails/v2/internal/servicebus" "github.com/wailsapp/wails/v2/internal/signal" "github.com/wailsapp/wails/v2/internal/subsystem" + "github.com/wailsapp/wails/v2/pkg/menu" "github.com/wailsapp/wails/v2/pkg/options" ) @@ -28,6 +32,7 @@ type App struct { event *subsystem.Event binding *subsystem.Binding call *subsystem.Call + menu *subsystem.Menu dispatcher *messagedispatcher.Dispatcher // Indicates if the app is in debug mode @@ -128,6 +133,25 @@ func (a *App) Run() error { a.event = event a.event.Start() + // Start the menu subsystem + var platformMenu *menu.Menu + switch goruntime.GOOS { + case "darwin": + platformMenu = a.options.Mac.Menu + // case "linux": + // platformMenu = a.options.Linux.Menu + // case "windows": + // platformMenu = a.options.Windows.Menu + default: + return fmt.Errorf("unsupported OS: %s", goruntime.GOOS) + } + menu, err := subsystem.NewMenu(platformMenu, a.servicebus, a.logger) + if err != nil { + return err + } + a.menu = menu + a.menu.Start() + // Start the call subsystem call, err := subsystem.NewCall(a.servicebus, a.logger, a.bindings.DB(), a.runtime.GoRuntime()) if err != nil { diff --git a/v2/internal/ffenestri/ffenestri_darwin.c b/v2/internal/ffenestri/ffenestri_darwin.c index 26a5d5f6c..75b3f00a6 100644 --- a/v2/internal/ffenestri/ffenestri_darwin.c +++ b/v2/internal/ffenestri/ffenestri_darwin.c @@ -20,6 +20,8 @@ #define GET_FRAME(receiver) ((CGRect(*)(id, SEL))objc_msgSend_stret)(receiver, s("frame")) #define GET_BOUNDS(receiver) ((CGRect(*)(id, SEL))objc_msgSend_stret)(receiver, s("bounds")) +#define STREQ(a,b) strncmp(a, b, strlen(b)) == 0 + #define ON_MAIN_THREAD(str) dispatch( ^{ str; } ) #define MAIN_WINDOW_CALL(str) msg(app->mainWindow, s((str))) @@ -49,7 +51,7 @@ #define NSEventModifierFlagCommand 1 << 20 #define NSEventModifierFlagOption 1 << 19 - +#define NSEventModifierFlagShift 1 << 17 // Unbelievably, if the user swaps their button preference @@ -156,6 +158,10 @@ struct Application { int useToolBar; int hideToolbarSeparator; int windowBackgroundIsTranslucent; + + // Menu + const char *menuAsJSON; + id menubar; // User Data char *HTML; @@ -189,6 +195,16 @@ void Debug(struct Application *app, const char *message, ... ) { } } +void Fatal(struct Application *app, const char *message, ... ) { + const char *temp = concat("LFFfenestri (C) | ", message); + va_list args; + va_start(args, message); + vsnprintf(logbuffer, MAXMESSAGE, temp, args); + app->sendMessageToBackend(&logbuffer[0]); + free((void*)temp); + va_end(args); +} + void TitlebarAppearsTransparent(struct Application* app) { app->titlebarAppearsTransparent = 1; } @@ -292,6 +308,15 @@ void messageHandler(id self, SEL cmd, id contentController, id message) { } } +// Callback for menu items +void menuItemPressed(id self, SEL cmd, id sender) { + const char *callbackID = (const char *)msg(msg(sender, s("representedObject")), s("pointerValue")); + printf("Got callback ID: %s\n", callbackID); + const char *message = concat("MC", callbackID); + messageFromWindowCallback(message); + free((void*)message); +} + // closeWindow is called when the close button is pressed void closeWindow(id self, SEL cmd, id sender) { printf("\n\n\ncloseWindow called!!!!\n\n\n"); @@ -371,6 +396,8 @@ void* NewApplication(const char *title, int width, int height, int resizable, in result->vibrancyLayer = NULL; result->delegate = NULL; + // Menu + result->menuAsJSON = NULL; result->titlebarAppearsTransparent = 0; result->webviewIsTranparent = 0; @@ -745,6 +772,11 @@ void SetDebug(void *applicationPointer, int flag) { debug = flag; } +// SetMenu sets the initial menu for the application +void SetMenu(struct Application *app, const char *menuAsJSON) { + app->menuAsJSON = menuAsJSON; +} + void SetBindings(struct Application *app, const char *bindings) { const char* temp = concat("window.wailsbindings = \"", bindings); const char* jscall = concat(temp, "\";"); @@ -832,6 +864,9 @@ void createDelegate(struct Application *app) { class_addMethod(delegateClass, s("applicationShouldTerminateAfterLastWindowClosed:"), (IMP) yes, "c@:@"); class_addMethod(delegateClass, s("windowWillClose:"), (IMP) closeWindow, "v@:@"); + // Menu Callbacks + class_addMethod(delegateClass, s("menuCallback:"), (IMP)menuItemPressed, "v@:@"); + // Script handler class_addMethod(delegateClass, s("userContentController:didReceiveScriptMessage:"), (IMP) messageHandler, "v@:@@"); objc_registerClassPair(delegateClass); @@ -901,70 +936,292 @@ id createMenuItemNoAutorelease( id title, const char *action, const char *key) { } id createMenu(id title) { - id menu = ALLOC("NSMenu"); - msg(menu, s("initWithTitle:"), title); - msg(menu, s("autorelease")); - return menu; + id menu = ALLOC("NSMenu"); + msg(menu, s("initWithTitle:"), title); + msg(menu, s("autorelease")); + return menu; } -id addMenuItem(id menu, const char *title, const char *action, const char *key) { +id addMenuItem(id menu, const char *title, const char *action, const char *key, bool enabled) { id item = createMenuItem(str(title), action, key); + msg(item, s("setEnabled:"), enabled); msg(menu, s("addItem:"), item); return item; } + +id addCallbackMenuItem(id menu, const char *title, const char *menuid, const char *key, bool enabled) { + id item = ALLOC("NSMenuItem"); + id wrappedId = msg(c("NSValue"), s("valueWithPointer:"), menuid); + msg(item, s("setRepresentedObject:"), wrappedId); + msg(item, s("initWithTitle:action:keyEquivalent:"), str(title), s("menuCallback:"), str(key)); + msg(item, s("setEnabled:"), enabled); + msg(item, s("autorelease")); + msg(menu, s("addItem:"), item); + return item; +} + void addSeparator(id menu) { id item = msg(c("NSMenuItem"), s("separatorItem")); msg(menu, s("addItem:"), item); } -void addDefaultMenu() { - - id menubar = createMenu(str("")); - - id appName = msg(msg(c("NSProcessInfo"), s("processInfo")), s("processName")); - - id appMenuItem = createMenuItemNoAutorelease(appName, NULL, ""); - +void createDefaultAppMenu(id parentMenu) { // App Menu + id appName = msg(msg(c("NSProcessInfo"), s("processInfo")), s("processName")); + id appMenuItem = createMenuItemNoAutorelease(appName, NULL, ""); id appMenu = createMenu(appName); msg(appMenuItem, s("setSubmenu:"), appMenu); - msg(menubar, s("addItem:"), appMenuItem); + msg(parentMenu, s("addItem:"), appMenuItem); id title = msg(str("Hide "), s("stringByAppendingString:"), appName); id item = createMenuItem(title, "hide:", "h"); msg(appMenu, s("addItem:"), item); - id hideOthers = addMenuItem(appMenu, "Hide Others", "hideOtherApplications:", "h"); + id hideOthers = addMenuItem(appMenu, "Hide Others", "hideOtherApplications:", "h", TRUE); msg(hideOthers, s("setKeyEquivalentModifierMask:"), (NSEventModifierFlagOption | NSEventModifierFlagCommand)); - addMenuItem(appMenu, "Show All", "unhideAllApplications:", ""); + addMenuItem(appMenu, "Show All", "unhideAllApplications:", "", TRUE); addSeparator(appMenu); title = msg(str("Quit "), s("stringByAppendingString:"), appName); item = createMenuItem(title, "terminate:", "q"); msg(appMenu, s("addItem:"), item); +} + +void createDefaultEditMenu(id parentMenu) { // Edit Menu id editMenuItem = createMenuItemNoAutorelease(str("Edit"), NULL, ""); id editMenu = createMenu(str("Edit")); msg(editMenuItem, s("setSubmenu:"), editMenu); - msg(menubar, s("addItem:"), editMenuItem); + msg(parentMenu, s("addItem:"), editMenuItem); - addMenuItem(editMenu, "Undo", "undo:", "z"); - addMenuItem(editMenu, "Redo", "redo:", "y"); + addMenuItem(editMenu, "Undo", "undo:", "z", TRUE); + addMenuItem(editMenu, "Redo", "redo:", "y", TRUE); addSeparator(editMenu); - addMenuItem(editMenu, "Cut", "cut:", "x"); - addMenuItem(editMenu, "Copy", "copy:", "c"); - addMenuItem(editMenu, "Paste", "paste:", "v"); - addMenuItem(editMenu, "Select All", "selectAll:", "a"); + addMenuItem(editMenu, "Cut", "cut:", "x", TRUE); + addMenuItem(editMenu, "Copy", "copy:", "c", TRUE); + addMenuItem(editMenu, "Paste", "paste:", "v", TRUE); + addMenuItem(editMenu, "Select All", "selectAll:", "a", TRUE); +} +void parseMenuRole(struct Application *app, id parentMenu, JsonNode *item) { + const char *roleName = item->string_; + + if ( STREQ(roleName, "appMenu") ) { + createDefaultAppMenu(parentMenu); + return; + } + if ( STREQ(roleName, "editMenu")) { + createDefaultEditMenu(parentMenu); + return; + } + if ( STREQ(roleName, "hide")) { + addMenuItem(parentMenu, "Hide Window", "hide:", "h", TRUE); + return; + } + if ( STREQ(roleName, "hideothers")) { + id hideOthers = addMenuItem(parentMenu, "Hide Others", "hideOtherApplications:", "h", TRUE); + msg(hideOthers, s("setKeyEquivalentModifierMask:"), (NSEventModifierFlagOption | NSEventModifierFlagCommand)); + return; + } + if ( STREQ(roleName, "unhide")) { + addMenuItem(parentMenu, "Show All", "unhideAllApplications:", "", TRUE); + return; + } + if ( STREQ(roleName, "front")) { + addMenuItem(parentMenu, "Bring All to Front", "arrangeInFront:", "", TRUE); + return; + } + if ( STREQ(roleName, "undo")) { + addMenuItem(parentMenu, "Undo", "undo:", "z", TRUE); + return; + } + if ( STREQ(roleName, "redo")) { + addMenuItem(parentMenu, "Redo", "redo:", "y", TRUE); + return; + } + if ( STREQ(roleName, "cut")) { + addMenuItem(parentMenu, "Cut", "cut:", "x", TRUE); + return; + } + if ( STREQ(roleName, "copy")) { + addMenuItem(parentMenu, "Copy", "copy:", "c", TRUE); + return; + } + if ( STREQ(roleName, "paste")) { + addMenuItem(parentMenu, "Paste", "paste:", "v", TRUE); + return; + } + if ( STREQ(roleName, "delete")) { + addMenuItem(parentMenu, "Delete", "delete:", "", TRUE); + return; + } + if( STREQ(roleName, "pasteandmatchstyle")) { + id pasteandmatchstyle = addMenuItem(parentMenu, "Paste and Match Style", "pasteandmatchstyle:", "v", TRUE); + msg(pasteandmatchstyle, s("setKeyEquivalentModifierMask:"), (NSEventModifierFlagOption | NSEventModifierFlagShift | NSEventModifierFlagCommand)); + } + if ( STREQ(roleName, "selectall")) { + addMenuItem(parentMenu, "Select All", "selectAll:", "a", TRUE); + return; + } + if ( STREQ(roleName, "minimize")) { + addMenuItem(parentMenu, "Minimize", "miniaturize:", "m", TRUE); + return; + } + if ( STREQ(roleName, "zoom")) { + addMenuItem(parentMenu, "Zoom", "performZoom:", "", TRUE); + return; + } + if ( STREQ(roleName, "quit")) { + addMenuItem(parentMenu, "Quit (More work TBD)", "terminate:", "q", TRUE); + return; + } + if ( STREQ(roleName, "togglefullscreen")) { + addMenuItem(parentMenu, "Toggle Full Screen", "toggleFullScreen:", "f", TRUE); + return; + } + +} + +const char* getJSONString(JsonNode *item, const char* key) { + // Get key + JsonNode *node = json_find_member(item, key); + const char *result = ""; + if ( node != NULL && node->tag == JSON_STRING) { + result = node->string_; + } + return result; +} + +bool getJSONBool(JsonNode *item, const char* key, bool *result) { + JsonNode *node = json_find_member(item, key); + if ( node != NULL && node->tag == JSON_BOOL) { + *result = node->bool_; + return true; + } + return false; +} + +void parseNormalMenuItem(struct Application *app, id parentMenu, JsonNode *item) { + + // Get the label + const char *label = getJSONString(item, "Label"); + if ( label == NULL) { + label = "(empty)"; + } + + const char *menuid = getJSONString(item, "Id"); + if ( menuid == NULL) { + menuid = ""; + } + + bool enabled = true; + getJSONBool(item, "Enabled", &enabled); + + const char *accelerator = ""; + + printf("Parsing Normal Menu Item %s!!!\n", label); + + + addCallbackMenuItem(parentMenu, label, menuid, accelerator, enabled); +} + +void parseMenuItem(struct Application *app, id parentMenu, JsonNode *item) { + // Get the role + JsonNode *role = json_find_member(item, "Role"); + if( role != NULL ) { + printf("Parsing MENU ROLE %s!!!\n", role->string_); + parseMenuRole(app, parentMenu, role); + return; + } + + // Check if this is a submenu + JsonNode *submenu = json_find_member(item, "SubMenu"); + if( submenu != NULL ) { + printf("Parsing SUBMENU!!!\n"); + + // Get the label + JsonNode *menuNameNode = json_find_member(item, "Label"); + const char *name = ""; + if ( menuNameNode != NULL) { + name = menuNameNode->string_; + } + + id thisMenuItem = createMenuItemNoAutorelease(str(name), NULL, ""); + id thisMenu = createMenu(str(name)); + + msg(thisMenuItem, s("setSubmenu:"), thisMenu); + msg(parentMenu, s("addItem:"), thisMenuItem); + + // Loop over submenu items + JsonNode *item; + json_foreach(item, submenu) { + // Get item label + parseMenuItem(app, thisMenu, item); + printf("Parsing submenu item for '%s'!!!\n", name); + } + + return; + } + + // Get the Type + JsonNode *type = json_find_member(item, "Type"); + if( type != NULL ) { + if( STREQ(type->string_, "Normal")) { + parseNormalMenuItem(app, parentMenu, item); + return; + } + + if ( STREQ(type->string_, "Separator")) { + addSeparator(parentMenu); + return; + } + return; + } +} + +void parseMenu(struct Application *app, id parentMenu, JsonNode *menu) { + JsonNode *items = json_find_member(menu, "Items"); + if( items == NULL ) { + // Parse error! + Fatal(app, "Unable to find Items:", app->menuAsJSON); + return; + } + + // Iterate items + JsonNode *item; + json_foreach(item, items) { + // Get item label + parseMenuItem(app, parentMenu, item); + } +} + +void parseMenuData(struct Application *app) { + + // Create a new menu bar + id menubar = createMenu(str("")); + + // Parse the menu json + JsonNode *menuData = json_decode(app->menuAsJSON); + + if( menuData == NULL ) { + // Parse error! + Fatal(app, "Unable to parse Menu JSON:", app->menuAsJSON); + return; + } + + parseMenu(app, menubar, menuData); + + // Apply the menu bar msg(msg(c("NSApplication"), s("sharedApplication")), s("setMainMenu:"), menubar); } + void Run(struct Application *app, int argc, char **argv) { processDecorations(app); @@ -1129,7 +1386,9 @@ void Run(struct Application *app, int argc, char **argv) { msg(wkwebview, s("setValue:forKey:"), msg(c("NSNumber"), s("numberWithBool:"), 0), str("drawsBackground")); } - addDefaultMenu(app); + if( app->menuAsJSON != NULL ) { + parseMenuData(app); + } // Finally call run Debug(app, "Run called"); diff --git a/v2/internal/ffenestri/ffenestri_darwin.go b/v2/internal/ffenestri/ffenestri_darwin.go index 872399445..6a4e7af51 100644 --- a/v2/internal/ffenestri/ffenestri_darwin.go +++ b/v2/internal/ffenestri/ffenestri_darwin.go @@ -14,10 +14,12 @@ extern void DisableFrame(void *); extern void SetAppearance(void *, const char *); extern void WebviewIsTransparent(void *); extern void SetWindowBackgroundIsTranslucent(void *); +extern void SetMenu(void *, const char *); */ import "C" +import "encoding/json" -func (a *Application) processPlatformSettings() { +func (a *Application) processPlatformSettings() error { mac := a.config.Mac titlebar := mac.TitleBar @@ -64,4 +66,15 @@ func (a *Application) processPlatformSettings() { if mac.WindowBackgroundIsTranslucent { C.SetWindowBackgroundIsTranslucent(a.app) } + + // Process menu + if mac.Menu != nil { + menuJson, err := json.Marshal(mac.Menu) + if err != nil { + return err + } + C.SetMenu(a.app, a.string2CString(string(menuJson))) + } + + return nil } diff --git a/v2/internal/messagedispatcher/message/menu.go b/v2/internal/messagedispatcher/message/menu.go new file mode 100644 index 000000000..106c5ce47 --- /dev/null +++ b/v2/internal/messagedispatcher/message/menu.go @@ -0,0 +1,43 @@ +package message + +import ( + "fmt" + + "github.com/wailsapp/wails/v2/pkg/menu" +) + +// MenuOnMessage is used to emit listener registration requests +// on the service bus +type MenuOnMessage struct { + // MenuID is the id of the menu item we are interested in + MenuID string + // Callback is called when the menu is clicked + Callback func(*menu.MenuItem) +} + +// menuMessageParser does what it says on the tin! +func menuMessageParser(message string) (*parsedMessage, error) { + + // Sanity check: Menu messages must be at least 2 bytes + if len(message) < 3 { + return nil, fmt.Errorf("event message was an invalid length") + } + + var topic string + var data interface{} + + // Switch the message type + switch message[1] { + case 'C': + callbackid := message[2:] + topic = "menu:clicked" + data = callbackid + default: + return nil, fmt.Errorf("invalid menu message: %s", message) + } + + // Create a new parsed message struct + parsedMessage := &parsedMessage{Topic: topic, Data: data} + + return parsedMessage, nil +} diff --git a/v2/internal/messagedispatcher/message/messageparser.go b/v2/internal/messagedispatcher/message/messageparser.go index 8a76b82c4..a8c0dd5a7 100644 --- a/v2/internal/messagedispatcher/message/messageparser.go +++ b/v2/internal/messagedispatcher/message/messageparser.go @@ -18,6 +18,7 @@ var messageParsers = map[byte]func(string) (*parsedMessage, error){ 'W': windowMessageParser, 'D': dialogMessageParser, 'S': systemMessageParser, + 'M': menuMessageParser, } // Parse will attempt to parse the given message diff --git a/v2/internal/runtime/menu.go b/v2/internal/runtime/menu.go new file mode 100644 index 000000000..c4eabdc52 --- /dev/null +++ b/v2/internal/runtime/menu.go @@ -0,0 +1,31 @@ +package runtime + +import ( + "github.com/wailsapp/wails/v2/internal/messagedispatcher/message" + "github.com/wailsapp/wails/v2/internal/servicebus" + "github.com/wailsapp/wails/v2/pkg/menu" +) + +// Menu defines all Menu related operations +type Menu interface { + On(menuID string, callback func(*menu.MenuItem)) +} + +type menuRuntime struct { + bus *servicebus.ServiceBus +} + +// newMenu creates a new Menu struct +func newMenu(bus *servicebus.ServiceBus) Menu { + return &menuRuntime{ + bus: bus, + } +} + +// On registers a listener for a particular event +func (m *menuRuntime) On(menuID string, callback func(*menu.MenuItem)) { + m.bus.Publish("menu:on", &message.MenuOnMessage{ + MenuID: menuID, + Callback: callback, + }) +} diff --git a/v2/internal/runtime/runtime.go b/v2/internal/runtime/runtime.go index 51dd99fd8..a69b71654 100644 --- a/v2/internal/runtime/runtime.go +++ b/v2/internal/runtime/runtime.go @@ -9,6 +9,7 @@ type Runtime struct { Window Window Dialog Dialog System System + Menu Menu Store *StoreProvider Log Log bus *servicebus.ServiceBus @@ -22,6 +23,7 @@ func New(serviceBus *servicebus.ServiceBus) *Runtime { Window: newWindow(serviceBus), Dialog: newDialog(serviceBus), System: newSystem(serviceBus), + Menu: newMenu(serviceBus), Log: newLog(serviceBus), bus: serviceBus, } diff --git a/v2/internal/subsystem/menu.go b/v2/internal/subsystem/menu.go new file mode 100644 index 000000000..745192ae9 --- /dev/null +++ b/v2/internal/subsystem/menu.go @@ -0,0 +1,171 @@ +package subsystem + +import ( + "strings" + "sync" + + "github.com/wailsapp/wails/v2/internal/logger" + "github.com/wailsapp/wails/v2/internal/messagedispatcher/message" + "github.com/wailsapp/wails/v2/internal/servicebus" + "github.com/wailsapp/wails/v2/pkg/menu" +) + +// eventListener holds a callback function which is invoked when +// the event listened for is emitted. It has a counter which indicates +// how the total number of events it is interested in. A value of zero +// means it does not expire (default). +// type eventListener struct { +// callback func(...interface{}) // Function to call with emitted event data +// counter int // The number of times this callback may be called. -1 = infinite +// delete bool // Flag to indicate that this listener should be deleted +// } + +// Menu is the subsystem that handles the operation of menus. It manages all service bus messages +// starting with "menu". +type Menu struct { + quitChannel <-chan *servicebus.Message + menuChannel <-chan *servicebus.Message + running bool + + // Event listeners + listeners map[string][]func(*menu.MenuItem) + menuItems map[string]*menu.MenuItem + notifyLock sync.RWMutex + + // logger + logger logger.CustomLogger +} + +// NewMenu creates a new menu subsystem +func NewMenu(initialMenu *menu.Menu, bus *servicebus.ServiceBus, logger *logger.Logger) (*Menu, error) { + + // Register quit channel + quitChannel, err := bus.Subscribe("quit") + if err != nil { + return nil, err + } + + // Subscribe to menu messages + menuChannel, err := bus.Subscribe("menu") + if err != nil { + return nil, err + } + + result := &Menu{ + quitChannel: quitChannel, + menuChannel: menuChannel, + logger: logger.CustomLogger("Menu Subsystem"), + listeners: make(map[string][]func(*menu.MenuItem)), + menuItems: make(map[string]*menu.MenuItem), + } + + // Build up list of item/id pairs + result.processMenu(initialMenu) + + return result, nil +} + +// Start the subsystem +func (m *Menu) Start() error { + + m.logger.Trace("Starting") + + m.running = true + + // Spin off a go routine + go func() { + for m.running { + select { + case <-m.quitChannel: + m.running = false + break + case menuMessage := <-m.menuChannel: + splitTopic := strings.Split(menuMessage.Topic(), ":") + menuMessageType := splitTopic[1] + switch menuMessageType { + case "clicked": + if len(splitTopic) != 2 { + m.logger.Error("Received clicked message with invalid topic format. Expected 2 sections in topic, got %s", splitTopic) + continue + } + m.logger.Trace("Got Menu clicked Message: %s %+v", menuMessage.Topic(), menuMessage.Data()) + callbackID := menuMessage.Data().(string) + m.notifyListeners(callbackID) + case "on": + listenerDetails := menuMessage.Data().(*message.MenuOnMessage) + id := listenerDetails.MenuID + // Check we have a menu with that id + if m.menuItems[id] == nil { + m.logger.Error("cannot register listener for unknown menu id '%s'", id) + continue + } + // We do! Append the callback + m.listeners[id] = append(m.listeners[id], listenerDetails.Callback) + default: + m.logger.Error("unknown menu message: %+v", menuMessage) + } + } + } + + // Call shutdown + m.shutdown() + }() + + return nil +} + +func (m *Menu) processMenu(menu *menu.Menu) { + for _, item := range menu.Items { + m.processMenuItem(item) + } +} + +func (m *Menu) processMenuItem(item *menu.MenuItem) { + + if item.SubMenu != nil { + for _, submenuitem := range item.SubMenu { + m.processMenuItem(submenuitem) + } + return + } + + if item.Id != "" { + if m.menuItems[item.Id] != nil { + m.logger.Error("Menu id '%s' is used by multiple menu items: %s %s", m.menuItems[item.Id].Label, item.Label) + return + } + m.menuItems[item.Id] = item + } +} + +// Notifies listeners that the given menu was clicked +func (m *Menu) notifyListeners(menuid string) { + + // Get the menu item + menuItem := m.menuItems[menuid] + if menuItem == nil { + m.logger.Trace("Cannot process menuid %s - unknown", menuid) + return + } + // Get list of menu listeners + listeners := m.listeners[menuid] + if listeners == nil { + m.logger.Trace("No listeners for %s", menuid) + return + } + + // Lock the listeners + m.notifyLock.Lock() + + // Callback in goroutine + for _, listener := range listeners { + go listener(menuItem) + } + + // Unlock + m.notifyLock.Unlock() +} + +func (m *Menu) shutdown() { + m.logger.Trace("Shutdown") +} diff --git a/v2/pkg/menu/mac.go b/v2/pkg/menu/mac.go new file mode 100644 index 000000000..a91ce00c0 --- /dev/null +++ b/v2/pkg/menu/mac.go @@ -0,0 +1,10 @@ +package menu + +// DefaultMacMenu returns a default menu including the default +// Application and Edit menus. Use `.Append()` to add to it. +func DefaultMacMenu() *Menu { + return NewMenuFromItems( + AppMenu(), + EditMenu(), + ) +} diff --git a/v2/pkg/menu/menu.go b/v2/pkg/menu/menu.go new file mode 100644 index 000000000..4c3ae8f5a --- /dev/null +++ b/v2/pkg/menu/menu.go @@ -0,0 +1,24 @@ +package menu + +type Menu struct { + Items []*MenuItem +} + +func NewMenu() *Menu { + return &Menu{} +} + +func (m *Menu) Append(item *MenuItem) { + m.Items = append(m.Items, item) +} + +func NewMenuFromItems(first *MenuItem, rest ...*MenuItem) *Menu { + + var result = NewMenu() + result.Append(first) + for _, item := range rest { + result.Append(item) + } + + return result +} diff --git a/v2/pkg/menu/menuitem.go b/v2/pkg/menu/menuitem.go new file mode 100644 index 000000000..bec0f3cfc --- /dev/null +++ b/v2/pkg/menu/menuitem.go @@ -0,0 +1,30 @@ +package menu + +type MenuItem struct { + Id string `json:"Id,omitempty"` + Label string + Role Role `json:"Role,omitempty"` + Accelerator string `json:"Accelerator,omitempty"` + Type Type + Enabled bool + Visible bool + Checked bool + SubMenu []*MenuItem `json:"SubMenu,omitempty"` +} + +func Text(label string, id string) *MenuItem { + return &MenuItem{ + Id: id, + Label: label, + Type: NormalType, + Enabled: true, + Visible: true, + } +} + +// Separator provides a menu separator +func Separator() *MenuItem { + return &MenuItem{ + Type: SeparatorType, + } +} diff --git a/v2/pkg/menu/menuroles.go b/v2/pkg/menu/menuroles.go new file mode 100644 index 000000000..1ee62f28f --- /dev/null +++ b/v2/pkg/menu/menuroles.go @@ -0,0 +1,204 @@ +// Package menu provides all the functions and structs related to menus in a Wails application. +// Heavily inspired by Electron (c) 2013-2020 Github Inc. +// Electron License: https://github.com/electron/electron/blob/master/LICENSE +package menu + +type Role string + +const ( + AboutRole Role = "about" + UndoRole Role = "undo" + RedoRole Role = "redo" + CutRole Role = "cut" + CopyRole Role = "copy" + PasteRole Role = "paste" + PasteAndMatchStyleRole Role = "pasteAndMatchStyle" + SelectAllRole Role = "selectAll" + DeleteRole Role = "delete" + MinimizeRole Role = "minimize" + QuitRole Role = "quit" + TogglefullscreenRole Role = "togglefullscreen" + FileMenuRole Role = "fileMenu" + EditMenuRole Role = "editMenu" + ViewMenuRole Role = "viewMenu" + WindowMenuRole Role = "windowMenu" + AppMenuRole Role = "appMenu" + HideRole Role = "hide" + HideOthersRole Role = "hideOthers" + UnhideRole Role = "unhide" + FrontRole Role = "front" + ZoomRole Role = "zoom" + WindowSubMenuRole Role = "windowSubMenu" + HelpSubMenuRole Role = "helpSubMenu" + SeparatorItemRole Role = "separatorItem" +) + +// About provides a MenuItem with the About role +func About() *MenuItem { + return &MenuItem{ + Role: AboutRole, + } +} + +// Undo provides a MenuItem with the Undo role +func Undo() *MenuItem { + return &MenuItem{ + Role: UndoRole, + } +} + +// Redo provides a MenuItem with the Redo role +func Redo() *MenuItem { + return &MenuItem{ + Role: RedoRole, + } +} + +// Cut provides a MenuItem with the Cut role +func Cut() *MenuItem { + return &MenuItem{ + Role: CutRole, + } +} + +// Copy provides a MenuItem with the Copy role +func Copy() *MenuItem { + return &MenuItem{ + Role: CopyRole, + } +} + +// Paste provides a MenuItem with the Paste role +func Paste() *MenuItem { + return &MenuItem{ + Role: PasteRole, + } +} + +// PasteAndMatchStyle provides a MenuItem with the PasteAndMatchStyle role +func PasteAndMatchStyle() *MenuItem { + return &MenuItem{ + Role: PasteAndMatchStyleRole, + } +} + +// SelectAll provides a MenuItem with the SelectAll role +func SelectAll() *MenuItem { + return &MenuItem{ + Role: SelectAllRole, + } +} + +// Delete provides a MenuItem with the Delete role +func Delete() *MenuItem { + return &MenuItem{ + Role: DeleteRole, + } +} + +// Minimize provides a MenuItem with the Minimize role +func Minimize() *MenuItem { + return &MenuItem{ + Role: MinimizeRole, + } +} + +// Quit provides a MenuItem with the Quit role +func Quit() *MenuItem { + return &MenuItem{ + Role: QuitRole, + } +} + +// Togglefullscreen provides a MenuItem with the Togglefullscreen role +func Togglefullscreen() *MenuItem { + return &MenuItem{ + Role: TogglefullscreenRole, + } +} + +// FileMenu provides a MenuItem with the whole default "File" menu (Close / Quit) +func FileMenu() *MenuItem { + return &MenuItem{ + Role: FileMenuRole, + } +} + +// EditMenu provides a MenuItem with the whole default "Edit" menu (Undo, Copy, etc.). +func EditMenu() *MenuItem { + return &MenuItem{ + Role: EditMenuRole, + } +} + +// ViewMenu provides a MenuItem with the whole default "View" menu (Reload, Toggle Developer Tools, etc.) +func ViewMenu() *MenuItem { + return &MenuItem{ + Role: ViewMenuRole, + } +} + +// WindowMenu provides a MenuItem with the whole default "Window" menu (Minimize, Zoom, etc.). +func WindowMenu() *MenuItem { + return &MenuItem{ + Role: WindowMenuRole, + } +} + +// These roles are Mac only + +// AppMenu provides a MenuItem with the whole default "App" menu (About, Services, etc.) +func AppMenu() *MenuItem { + return &MenuItem{ + Role: AppMenuRole, + } +} + +// Hide provides a MenuItem that maps to the hide action. +func Hide() *MenuItem { + return &MenuItem{ + Role: HideRole, + } +} + +// HideOthers provides a MenuItem that maps to the hideOtherApplications action. +func HideOthers() *MenuItem { + return &MenuItem{ + Role: HideOthersRole, + } +} + +// Unhide provides a MenuItem that maps to the unhideAllApplications action. +func Unhide() *MenuItem { + return &MenuItem{ + Role: UnhideRole, + } +} + +// Front provides a MenuItem that maps to the arrangeInFront action. +func Front() *MenuItem { + return &MenuItem{ + Role: FrontRole, + } +} + +// Zoom provides a MenuItem that maps to the performZoom action. +func Zoom() *MenuItem { + return &MenuItem{ + Role: ZoomRole, + } +} + +// WindowSubMenu provides a MenuItem with the "Window" submenu. +func WindowSubMenu() *MenuItem { + return &MenuItem{ + Role: WindowSubMenuRole, + } +} + +// HelpSubMenu provides a MenuItem with the "Help" submenu. +func HelpSubMenu() *MenuItem { + return &MenuItem{ + Role: HelpSubMenuRole, + } +} diff --git a/v2/pkg/menu/submenu.go b/v2/pkg/menu/submenu.go new file mode 100644 index 000000000..815828cb1 --- /dev/null +++ b/v2/pkg/menu/submenu.go @@ -0,0 +1,10 @@ +package menu + +// SubMenu creates a new submenu which may be added to other +// menus +func SubMenu(label string, items []*MenuItem) *MenuItem { + return &MenuItem{ + Label: label, + SubMenu: items, + } +} diff --git a/v2/pkg/menu/type.go b/v2/pkg/menu/type.go new file mode 100644 index 000000000..e5bf1fabf --- /dev/null +++ b/v2/pkg/menu/type.go @@ -0,0 +1,17 @@ +package menu + +// Type of the menu item +type Type string + +const ( + // NormalType is the Normal menuitem type + NormalType Type = "Normal" + // SeparatorType is the Separator menuitem type + SeparatorType Type = "Separator" + // SubmenuType is the Submenu menuitem type + SubmenuType Type = "Submenu" + // CheckboxType is the Checkbox menuitem type + CheckboxType Type = "Checkbox" + // RadioType is the Radio menuitem type + RadioType Type = "Radio" +) diff --git a/v2/pkg/options/default.go b/v2/pkg/options/default.go index 463416854..1f4ae25a4 100644 --- a/v2/pkg/options/default.go +++ b/v2/pkg/options/default.go @@ -2,6 +2,7 @@ package options import ( "github.com/wailsapp/wails/v2/pkg/logger" + "github.com/wailsapp/wails/v2/pkg/menu" "github.com/wailsapp/wails/v2/pkg/options/mac" ) @@ -17,6 +18,7 @@ var Default = &App{ Appearance: mac.DefaultAppearance, WebviewIsTransparent: false, WindowBackgroundIsTranslucent: false, + Menu: menu.DefaultMacMenu(), }, Logger: logger.NewDefaultLogger(), LogLevel: logger.INFO, diff --git a/v2/pkg/options/mac/mac.go b/v2/pkg/options/mac/mac.go index 920e0d9d2..05b10d3a2 100644 --- a/v2/pkg/options/mac/mac.go +++ b/v2/pkg/options/mac/mac.go @@ -1,9 +1,12 @@ package mac -// Options are options speific to Mac +import "github.com/wailsapp/wails/v2/pkg/menu" + +// Options are options specific to Mac type Options struct { TitleBar *TitleBar Appearance AppearanceType WebviewIsTransparent bool WindowBackgroundIsTranslucent bool + Menu *menu.Menu } diff --git a/v2/test/kitchensink/dialog.go b/v2/test/kitchensink/dialog.go index 0e0a667c8..0efc700d6 100644 --- a/v2/test/kitchensink/dialog.go +++ b/v2/test/kitchensink/dialog.go @@ -1,7 +1,10 @@ package main import ( + "fmt" + wails "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/menu" "github.com/wailsapp/wails/v2/pkg/options" ) @@ -14,6 +17,11 @@ type Dialog struct { func (l *Dialog) WailsInit(runtime *wails.Runtime) error { // Perform your setup here l.runtime = runtime + + // Setup Menu Listeners + l.runtime.Menu.On("hello", func(m *menu.MenuItem) { + fmt.Printf("The '%s' menu was clicked\n", m.Label) + }) return nil } diff --git a/v2/test/kitchensink/main.go b/v2/test/kitchensink/main.go index f1a1c5159..0301ab490 100644 --- a/v2/test/kitchensink/main.go +++ b/v2/test/kitchensink/main.go @@ -3,12 +3,38 @@ package main import ( wails "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/logger" + "github.com/wailsapp/wails/v2/pkg/menu" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/mac" ) func main() { + // Create menu + myMenu := menu.DefaultMacMenu() + + windowMenu := menu.SubMenu("Test", []*menu.MenuItem{ + menu.Togglefullscreen(), + menu.Minimize(), + menu.Zoom(), + + menu.Separator(), + + menu.Copy(), + menu.Cut(), + menu.Delete(), + + menu.Separator(), + + menu.Front(), + + menu.SubMenu("Test Submenu", []*menu.MenuItem{ + menu.Text("Hi!", "hello"), // Label = "Hi!", ID= "hello" + }), + }) + + myMenu.Append(windowMenu) + // Create application with options app := wails.CreateAppWithOptions(&options.App{ Title: "Kitchen Sink", @@ -21,6 +47,7 @@ func main() { WindowBackgroundIsTranslucent: true, // Comment out line below to see Window.SetTitle() work TitleBar: mac.TitleBarHiddenInset(), + Menu: myMenu, }, LogLevel: logger.TRACE, }) diff --git a/v2/test/kitchensink/window.go b/v2/test/kitchensink/window.go index 315549fcd..f3de20d8b 100644 --- a/v2/test/kitchensink/window.go +++ b/v2/test/kitchensink/window.go @@ -17,7 +17,6 @@ func (w *Window) WailsInit(runtime *wails.Runtime) error { } func (w *Window) SetTitle(title string) { - println("In SetTitle:", title) w.runtime.Window.SetTitle(title) }