diff --git a/cmd/hexecute/run_darwin.go b/cmd/hexecute/run_darwin.go index beb82f4..c56b1ac 100644 --- a/cmd/hexecute/run_darwin.go +++ b/cmd/hexecute/run_darwin.go @@ -101,6 +101,11 @@ func runMain(app *models.App, _ *config.Settings) { if err := window.RegisterHotkey(keyCode, modifiers); err != nil { log.Fatal("Failed to register global hotkey:", err) } + + // Add a menu-bar item so the accessory agent (no Dock icon) is visible and + // can be cast or quit from the menu bar, not only via the hot key. + window.SetupMenuBar() + log.Printf("Hexecute is running; press %s (or relaunch the app) to cast a gesture.", spec) for { diff --git a/pkg/cocoa/cocoa.go b/pkg/cocoa/cocoa.go index 2f2ca67..7bf3d00 100644 --- a/pkg/cocoa/cocoa.go +++ b/pkg/cocoa/cocoa.go @@ -83,6 +83,13 @@ func (w *CocoaWindow) RegisterHotkey(keyCode, modifiers uint32) error { return nil } +// SetupMenuBar adds a status-bar (menu bar) item for the resident agent, with a +// menu to cast on demand or quit. macOS-only; called from the background agent +// path, so it is not part of the platform.Window interface. +func (w *CocoaWindow) SetupMenuBar() { + C.cocoa_setup_menu_bar() +} + // WaitForShow blocks until a show is requested, either by the registered global // hot key or by a relaunch of the resident agent (reopen Apple event). func (w *CocoaWindow) WaitForShow() { diff --git a/pkg/cocoa/cocoa.h b/pkg/cocoa/cocoa.h index 86c4457..fa7eea6 100644 --- a/pkg/cocoa/cocoa.h +++ b/pkg/cocoa/cocoa.h @@ -21,6 +21,10 @@ void cocoa_hide(void); // Returns 0 on success. int cocoa_register_hotkey(uint32_t keyCode, uint32_t modifiers); +// cocoa_setup_menu_bar adds a status-bar (menu bar) item for the resident agent, +// with a menu to cast on demand or quit. Idempotent; must run on the main thread. +void cocoa_setup_menu_bar(void); + // cocoa_wait_for_show blocks, pumping the event loop, until a show is requested // by the hot key or by a relaunch (reopen Apple event). void cocoa_wait_for_show(void); diff --git a/pkg/cocoa/cocoa.m b/pkg/cocoa/cocoa.m index 2ae6c01..ce1ffae 100644 --- a/pkg/cocoa/cocoa.m +++ b/pkg/cocoa/cocoa.m @@ -80,6 +80,29 @@ - (BOOL)applicationShouldHandleReopen:(NSApplication *)sender hasVisibleWindows: static HexAppDelegate *g_app_delegate = nil; +// Menu-bar status item for the resident agent. The agent runs as an accessory +// (no Dock icon), so the status item is the only visible affordance: it shows +// Hexecute is running and offers a menu to cast on demand or quit. +static NSStatusItem *g_status_item = nil; + +// Targets for the status-item menu. cast funnels through request_show (the same +// path as the hot key and relaunch); quit terminates the resident agent. +@interface HexStatusController : NSObject +@end + +@implementation HexStatusController +- (void)cast:(id)sender { + (void)sender; + request_show(); +} +- (void)quit:(id)sender { + (void)sender; + [NSApp terminate:nil]; +} +@end + +static HexStatusController *g_status_controller = nil; + // Convert a macOS virtual key code to an XKB keysym. Only the keys the app // reacts to need mapping; everything else reports 0 (no key). static uint32_t map_keycode(unsigned short keyCode) { @@ -284,6 +307,61 @@ int cocoa_register_hotkey(uint32_t keyCode, uint32_t modifiers) { } } +// cocoa_setup_menu_bar adds a status-bar item (right side of the menu bar) for +// the resident agent, with a menu to cast on demand or quit. Idempotent and a +// no-op if a status item already exists. Must run on the main thread. +void cocoa_setup_menu_bar(void) { + @autoreleasepool { + if (g_status_item) { + return; + } + + g_status_controller = [[HexStatusController alloc] init]; + + // statusItemWithLength: returns an autoreleased item and the system + // status bar does not keep a reliable strong reference, so under manual + // reference counting (cgo compiles this without ARC) it must be retained + // or it is deallocated when this pool drains and the icon never appears. + g_status_item = [[[NSStatusBar systemStatusBar] + statusItemWithLength:NSVariableStatusItemLength] retain]; + + // Prefer a template SF Symbol (the system tints it for light/dark menu + // bars); wand.and.stars matches the spell-casting metaphor. Fall back to + // a glyph title if the symbol is unavailable. LSMinimumSystemVersion is + // 11.0, where imageWithSystemSymbolName exists. + NSImage *icon = nil; + if (@available(macOS 11.0, *)) { + icon = [NSImage imageWithSystemSymbolName:@"wand.and.stars" + accessibilityDescription:@"Hexecute"]; + } + if (icon) { + [icon setTemplate:YES]; + g_status_item.button.image = icon; + } else { + g_status_item.button.title = @"✦"; + } + g_status_item.button.toolTip = @"Hexecute"; + + NSMenu *menu = [[NSMenu alloc] init]; + + NSMenuItem *castItem = [[NSMenuItem alloc] initWithTitle:@"Cast Gesture" + action:@selector(cast:) + keyEquivalent:@""]; + [castItem setTarget:g_status_controller]; + [menu addItem:castItem]; + + [menu addItem:[NSMenuItem separatorItem]]; + + NSMenuItem *quitItem = [[NSMenuItem alloc] initWithTitle:@"Quit Hexecute" + action:@selector(quit:) + keyEquivalent:@"q"]; + [quitItem setTarget:g_status_controller]; + [menu addItem:quitItem]; + + g_status_item.menu = menu; + } +} + // cocoa_wait_for_show blocks (pumping the event loop, so the hot key, reopen // Apple event, and other events are dispatched) until a show is requested by the // hot key or a relaunch. @@ -413,6 +491,11 @@ void cocoa_clear_last_key(void) { void cocoa_destroy(void) { [NSCursor unhide]; + if (g_status_item) { + [[NSStatusBar systemStatusBar] removeStatusItem:g_status_item]; + [g_status_item release]; + g_status_item = nil; + } if (g_context) { [g_context clearDrawable]; g_context = nil;