app,app/internal/window,io/system: macOS menus v1 PROPOSED

Larry Clapp: 1
 app,app/internal/window,io/system: macOS menus

 6 files changed, 144 insertions(+), 5 deletions(-)
I took a look at the Fyne code for macOS menus and, broadly speaking,
they take a similar approach: assign each menu item a unique integer
tag, and call a single callback with the tag when the menu item is
chosen.  I don't call a callback, I send an event, but the idea's the

So I'm cautiously optimistic that this approach will work cross-platform.

I haven't actually *used* this code in my app yet, so I'm not sure yet
how to determine which window or event loop should receive the event
(right now it's random), or having gotten it, how to determine what to
do with it after that.  (E.g. for cut/copy/paste events, where
probably some specific widget should process it.)  I actually wanted
this functionality for a "New Window" menu item, and that doesn't care
what event loop processes it.

I imagine most apps will only have a single window / top-level event
loop, and they'll have less of a problem with this.

-- L

On Wed, Jul 22, 2020 at 12:23 PM Larry Clapp <larry@theclapp.org> wrote:
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~eliasnaur/gio-patches/patches/11684/mbox | git am -3
Learn more about email & git
View this thread in the archives

[PATCH] app,app/internal/window,io/system: macOS menus Export this patch

A strawman interface to the macOS menuing system.

Signed-off-by: Larry Clapp <larry@theclapp.org>
 app/app.go                      |  6 +++
 app/internal/window/os_macos.go | 55 +++++++++++++++++++++++++++
 app/internal/window/os_macos.m  | 66 +++++++++++++++++++++++++++++++--
 example/go.mod                  |  4 +-
 example/hello/hello.go          | 12 ++++++
 io/system/system.go             |  6 +++
 6 files changed, 144 insertions(+), 5 deletions(-)

Discussion: This probably isn't good enough for inclusion as-is, but I wanted
to see what y'all thought.

diff --git a/app/app.go b/app/app.go
index d21224e..c448858 100644
--- a/app/app.go
+++ b/app/app.go
@@ -9,6 +9,8 @@ import (

type MenuItem = window.MenuItem

// extraArgs contains extra arguments to append to
// os.Args. The arguments are separated with |.
// Useful for running programs on mobiles where the
@@ -46,3 +48,7 @@ func DataDir() (string, error) {
func Main() {

func Menu(title string, items ...MenuItem) {
	window.Menu(title, items...)
diff --git a/app/internal/window/os_macos.go b/app/internal/window/os_macos.go
index ef95b70..7467153 100644
--- a/app/internal/window/os_macos.go
+++ b/app/internal/window/os_macos.go
@@ -8,6 +8,7 @@ import (
@@ -44,6 +45,11 @@ __attribute__ ((visibility ("hidden"))) CFTypeRef gio_createWindow(CFTypeRef vie
__attribute__ ((visibility ("hidden"))) void gio_makeKeyAndOrderFront(CFTypeRef windowRef);
__attribute__ ((visibility ("hidden"))) NSPoint gio_cascadeTopLeftFromPoint(CFTypeRef windowRef, NSPoint topLeft);
__attribute__ ((visibility ("hidden"))) void gio_close(CFTypeRef windowRef);
__attribute__ ((visibility ("hidden"))) CFTypeRef gio_newMenuItem(const char *title, const char *keyEquivalent, int tag);
__attribute__ ((visibility ("hidden"))) CFTypeRef gio_newMenu(const char *title);
__attribute__ ((visibility ("hidden"))) void gio_menuAddItem(CFTypeRef menu, CFTypeRef menuItem);
__attribute__ ((visibility ("hidden"))) CFTypeRef gio_newSubMenu(CFTypeRef subMenu);
__attribute__ ((visibility ("hidden"))) CFTypeRef gio_mainMenu();
import "C"

@@ -62,6 +68,12 @@ type window struct {
	scale float32

type MenuItem struct {
	Title         string
	KeyEquivalent string
	Tag           int

// viewMap is the mapping from Cocoa NSViews to Go windows.
var viewMap = make(map[C.CFTypeRef]*window)

@@ -150,6 +162,10 @@ func (w *window) setStage(stage system.Stage) {
	w.w.Event(system.StageEvent{Stage: stage})

func (w *window) sendMenu(tag int) {
	w.w.Event(system.MenuEvent{Tag: tag})

//export gio_onKeys
func gio_onKeys(view C.CFTypeRef, cstr *C.char, ti C.double, mods C.NSUInteger) {
	str := C.GoString(cstr)
@@ -303,6 +319,19 @@ func gio_onFinishLaunching() {

//export gio_onAppMenu
// Send the menu event to a random window. It's up to the app to route the
// event appropriately. (This may or may not be the right idea. But on the
// other hand, it doesn't seem right for multiple event loops to get a "Paste"
// event.)
func gio_onAppMenu(tag C.int) {
	for _, w := range viewMap {

func NewWindow(win Callbacks, opts *Options) error {
	errch := make(chan error)
@@ -458,3 +487,29 @@ func convertMods(mods C.NSUInteger) key.Modifiers {
	return kmods

func Menu(title string, items ...MenuItem) {
	var wg sync.WaitGroup
	runOnMain(func() {
		defer wg.Done()
		title := C.CString(title)
		defer C.free(unsafe.Pointer(title))
		subMenu := C.gio_newMenu(title)

		for _, item := range items {
			title := C.CString(item.Title)
			keyEq := C.CString(item.KeyEquivalent)
			defer func() {
			newWindowItem := C.gio_newMenuItem(title, keyEq, C.int(item.Tag))
			C.gio_menuAddItem(subMenu, newWindowItem)

		menu := C.gio_newSubMenu(subMenu)
		C.gio_menuAddItem(C.gio_mainMenu(), menu)
diff --git a/app/internal/window/os_macos.m b/app/internal/window/os_macos.m
index b8c0dee..1e97014 100644
--- a/app/internal/window/os_macos.m
+++ b/app/internal/window/os_macos.m
@@ -153,6 +153,47 @@ CFTypeRef gio_createWindow(CFTypeRef viewRef, const char *title, CGFloat width,

CFTypeRef gio_newMenu(const char *title) {
	@autoreleasepool {
		NSString *nsTitle = [NSString stringWithUTF8String:title];
		NSMenu *menu = [[NSMenu alloc] initWithTitle:nsTitle];
		return (__bridge_retained CFTypeRef)menu;

void gio_menuAddItem(CFTypeRef menu, CFTypeRef menuItem) {
	@autoreleasepool {
		NSMenu *nsMenu = (__bridge NSMenu *)menu;
		NSMenuItem *nsMenuItem = (__bridge NSMenuItem *)menuItem;
		[nsMenu addItem:nsMenuItem];

CFTypeRef gio_newSubMenu(CFTypeRef subMenu) {
	@autoreleasepool {
		NSMenu *nsSubMenu = (__bridge NSMenu *)subMenu;
		NSMenuItem *menu = [NSMenuItem new];
		[menu setSubmenu:nsSubMenu];
		return (__bridge_retained CFTypeRef)menu;

CFTypeRef gio_mainMenu() {
	return (__bridge_retained CFTypeRef)[NSApp mainMenu];

CFTypeRef gio_newMenuItem(const char *title, const char *keyEquivalent, int tag) {
	@autoreleasepool {
		NSString *nsTitle = [NSString stringWithUTF8String:title];
		NSString *nsKeyEq = [NSString stringWithUTF8String:keyEquivalent];
		NSMenuItem *menuItem = [[NSMenuItem alloc] initWithTitle:nsTitle
		[menuItem setTag:tag];
		return (__bridge_retained CFTypeRef)menuItem;

void gio_close(CFTypeRef windowRef) {
  NSWindow* window = (__bridge NSWindow *)windowRef;
  [window performClose:nil];
@@ -169,6 +210,11 @@ void gio_close(CFTypeRef windowRef) {
- (void)applicationWillUnhide:(NSNotification *)notification {
- (void)applicationMenu:(id) sender {
    NSMenuItem * item = (NSMenuItem*)sender;
    int tag = [item tag];

void gio_main() {
@@ -180,20 +226,32 @@ void gio_main() {

		NSMenuItem *mainMenu = [NSMenuItem new];

		NSMenu *menu = [NSMenu new];
		NSMenu *mainSubMenu = [NSMenu new];
		NSMenuItem *hideMenuItem = [[NSMenuItem alloc] initWithTitle:@"Hide"
		[menu addItem:hideMenuItem];
		[mainSubMenu addItem:hideMenuItem];
		NSMenuItem *quitMenuItem = [[NSMenuItem alloc] initWithTitle:@"Quit"
		[menu addItem:quitMenuItem];
		[mainMenu setSubmenu:menu];
		[mainSubMenu addItem:quitMenuItem];
		[mainMenu setSubmenu:mainSubMenu];
		NSMenu *menuBar = [NSMenu new];
		[menuBar addItem:mainMenu];
		[NSApp setMainMenu:menuBar];

		//NSMenuItem *newWindowItem = (__bridge NSMenuItem *)gio_newMenuItem("New Window", "n", 3);
		//NSMenu *fileSubMenu = (__bridge NSMenu *)gio_newMenu("File");
		//gio_menuAddItem((__bridge CFTypeRef)fileSubMenu, (__bridge CFTypeRef)newWindowItem);
		//NSMenuItem *fileMenu = (__bridge NSMenuItem *)gio_newSubMenu((__bridge CFTypeRef)fileSubMenu);
		//gio_menuAddItem(gio_mainMenu(), (__bridge CFTypeRef)fileMenu);

		//CFTypeRef newWindowItem = gio_newMenuItem("New Window", "n", 3);
		//CFTypeRef fileSubMenu = gio_newMenu("File");
		//gio_menuAddItem(fileSubMenu, newWindowItem);
		//CFTypeRef fileMenu = gio_newSubMenu(fileSubMenu);
		//gio_menuAddItem(gio_mainMenu(), fileMenu);

		globalWindowDel = [[GioWindowDelegate alloc] init];

		[NSApp run];
diff --git a/example/go.mod b/example/go.mod
index 67ffb5b..215b286 100644
--- a/example/go.mod
+++ b/example/go.mod
@@ -2,8 +2,10 @@ module gioui.org/example

go 1.13

replace gioui.org => ../.

require (
	gioui.org v0.0.0-20200716125251-47efa26cfc05
	gioui.org v0.0.0-00010101000000-000000000000 // indirect
	github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7
	github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72
	github.com/google/go-github/v24 v24.0.1
diff --git a/example/hello/hello.go b/example/hello/hello.go
index ec8dfe0..b41bee3 100644
--- a/example/hello/hello.go
+++ b/example/hello/hello.go
@@ -19,9 +19,19 @@ import (

func AddFileMenu() {
		app.MenuItem{"New Window", "n", 1})
		app.MenuItem{"Cut", "x", 2},
		app.MenuItem{"Copy", "c", 3},
		app.MenuItem{"Paste", "v", 4},
func main() {
	go func() {
		w := app.NewWindow()
		if err := loop(w); err != nil {
@@ -46,6 +56,8 @@ func loop(w *app.Window) error {
			l.Alignment = text.Middle
		case system.MenuEvent:
			log.Printf("MenuEvent: Tag: %d", e.Tag)
diff --git a/io/system/system.go b/io/system/system.go
index 8abac6d..42b9d64 100644
--- a/io/system/system.go
+++ b/io/system/system.go
@@ -86,6 +86,11 @@ type CommandEvent struct {
	Cancel bool

// MenuEvent is sent when the user selects a menu item.
type MenuEvent struct {
	Tag int

// Stage of a Window.
type Stage uint8

@@ -122,3 +127,4 @@ func (StageEvent) ImplementsEvent()     {}
func (*CommandEvent) ImplementsEvent()  {}
func (DestroyEvent) ImplementsEvent()   {}
func (ClipboardEvent) ImplementsEvent() {}
func (MenuEvent) ImplementsEvent()      {}
This is almost entirely monkey-see-monkey-do with respect to the
Objective-C bits, plus a lot of Googling.  This may or may not be the
best way to do any of this.

-- L

On Wed, Jul 22, 2020 at 12:20 PM Larry Clapp <larry@theclapp.org> wrote:
Thank you for working on this. Like your other change, this change
introduces new concepts that I didn't find time to think through until
now. Apologies.

First, I'm not convinced it's worth abstracting this functionality to
other platforms that put menus inside windows. Creating Gio-native menu
widgets is more upfront work but pays off in simpler interfaces, less
maintenance and most importantly in matching menu and widget styles.
Of course, it would be nice to reuse menu descriptor types for both
macOS system menus and Gio menus.

Restricting system menus to macOS also eliminates the question of where
to deliver events. macOS menus are app global, so a natural answer is to
introduce an app global event channel. In addition, menus can be
separated out into a standalone package, independent on package
app/internal/window (I hope).

Small note: let's use event.Tag for menu tags instead of just ints. I
think the increased convenience is worth the additional complexity.

My suggestion:

	package menu // import "gioui.org/app/menu"

	type Item struct {
		Title string
		Key string
		Tag event.Tag

		// Items contain the submenu items below this one.
		Items []Item

	type Event struct {
		Tag event.Tag

		// TODO: more fields needed?

	// Events return the channel that receives an Event for each menu
	// item selected.
	func Events() <-chan Event // TODO: is <-chan event.Tag enough?

	// Set replaces the system menu with menu. Set is cheap to call for
	// menus that match the existing menu.
	func Set(menu []Item)

What do you think?