From 0d493bfdf21f3b416d8d224c2e6931bd8d44c97b Mon Sep 17 00:00:00 2001 From: Akinori Musha Date: Wed, 6 May 2026 13:54:03 +0900 Subject: [PATCH] macos: add menu key equivalent option Refs #12293 Related: #3493 (duplicates: #4374, #5091), #7013 Add `macos-menu-key-equivalents`, a macOS-specific setting for disabling menu key equivalents before they can intercept key events. It defaults to true, preserving existing menu behavior unless explicitly disabled. On macOS, AppKit evaluates menu key equivalents before Ghostty receives key events. This means a remap such as `key-remap = command=left_alt` still leaves `Command+N` captured by the New Window menu item before Ghostty can turn it into `Alt+N` for keybind and terminal input handling. Setting `macos-menu-key-equivalents` to false clears those menu shortcuts, so the key event reaches Ghostty instead of being consumed by AppKit. That allows Ghostty to apply `key-remap` first and dispatch the remapped key as terminal input or a Ghostty keybind. When disabled, this clears Ghostty-managed menu bindings and known standard macOS menu items, including dynamic items as their menus are opened. The original key equivalents are restored when the option is enabled again. Document the option and add Swift and Zig config coverage for it. AI usage: OpenAI Codex helped investigate, implement, test, and refine this change. I reviewed and tested the resulting code. --- macos/Sources/App/macOS/AppDelegate.swift | 13 ++++ macos/Sources/Ghostty/Ghostty.Config.swift | 8 ++ .../Ghostty/Ghostty.MenuShortcutManager.swift | 76 ++++++++++++++++++- macos/Tests/Ghostty/ConfigTests.swift | 10 +++ .../Ghostty/MenuShortcutManagerTests.swift | 70 +++++++++++++++++ src/config/Config.zig | 28 +++++-- 6 files changed, 199 insertions(+), 6 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 67ec9ac4a8e..b64f453062b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -268,6 +268,11 @@ class AppDelegate: NSObject, selector: #selector(ghosttyNewTab(_:)), name: Ghostty.Notification.ghosttyNewTab, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(menuWillOpen(_:)), + name: Notification.Name(rawValue: "NSMenuWillOpenNotification"), + object: nil) // Configure user notifications let actions = [ @@ -1209,6 +1214,8 @@ extension AppDelegate { // // syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) + menuShortcutManager.syncSystemMenuKeyEquivalents(config, menu: NSApp.mainMenu) + // Dock menu reloadDockMenu() } @@ -1220,6 +1227,12 @@ extension AppDelegate { @MainActor func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool { menuShortcutManager.performGhosttyBindingMenuKeyEquivalent(with: event) } + + @MainActor @objc private func menuWillOpen(_ notification: Notification) { + guard ghostty.config.config != nil, + !ghostty.config.macosMenuKeyEquivalents else { return } + menuShortcutManager.syncSystemMenuKeyEquivalents(ghostty.config, menu: notification.object as? NSMenu) + } } // MARK: Floating Windows diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 9b094f21c0b..589037e6f26 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -398,6 +398,14 @@ extension Ghostty { return v } + var macosMenuKeyEquivalents: Bool { + guard let config = self.config else { return true } + var v = false + let key = "macos-menu-key-equivalents" + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) + return v + } + var macosIcon: MacOSIcon { let defaultValue = MacOSIcon.official guard let config = self.config else { return defaultValue } diff --git a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift index d7145745f21..e9c8db26776 100644 --- a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift +++ b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift @@ -13,6 +13,8 @@ extension Ghostty { /// If multiple items map to the same shortcut, the most recent one wins. private var menuItemsByShortcut: [MenuShortcutKey: Weak] = [:] + private var systemMenuShortcuts: [ObjectIdentifier: StoredShortcut] = [:] + /// Reset our shortcut index since we're about to rebuild all menu bindings. func reset() { menuItemsByShortcut.removeAll(keepingCapacity: true) @@ -23,12 +25,42 @@ extension Ghostty { func syncMenuShortcut(_ config: Ghostty.Config, action: String?, menuItem: NSMenuItem?) { guard let menu = menuItem else { return } - if !updateMenuShortcut(config, action: action, menuItem: menu) { + if !config.macosMenuKeyEquivalents || + !updateMenuShortcut(config, action: action, menuItem: menu) { menu.keyEquivalent = "" menu.keyEquivalentModifierMask = [] } } + /// Syncs key equivalents for menu items that are provided by AppKit or static + /// macOS menu definitions instead of Ghostty keybinds. + func syncSystemMenuKeyEquivalents(_ config: Ghostty.Config, menu: NSMenu?) { + syncSystemMenuKeyEquivalents(config.macosMenuKeyEquivalents, menu: menu) + } + + func syncSystemMenuKeyEquivalents(_ enabled: Bool, menu: NSMenu?) { + guard let menu else { return } + + for item in menu.allItems where item.hasSuppressedSystemMenuKeyEquivalent { + let id = ObjectIdentifier(item) + if enabled { + if let shortcut = systemMenuShortcuts[id] { + item.keyEquivalent = shortcut.keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierMask + } + } else { + if systemMenuShortcuts[id] == nil { + systemMenuShortcuts[id] = .init(item) + } + if item.keyEquivalent.isEmpty && item.keyEquivalentModifierMask.isEmpty { + continue + } + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + } + } + } + /// Attempts to perform a menu key equivalent only for menu items that represent /// Ghostty keybind actions. This is important because it lets our surface dispatch /// bindings through the menu so they flash but also lets our surface override macOS built-ins @@ -73,6 +105,18 @@ extension Ghostty { } } +private extension Ghostty.MenuShortcutManager { + struct StoredShortcut { + let keyEquivalent: String + let modifierMask: NSEvent.ModifierFlags + + init(_ menuItem: NSMenuItem) { + keyEquivalent = menuItem.keyEquivalent + modifierMask = menuItem.keyEquivalentModifierMask + } + } +} + private extension Ghostty.MenuShortcutManager { /// Syncs a single menu shortcut for the given action. The action string is the same /// action string used for the Ghostty configuration. @@ -98,6 +142,36 @@ private extension Ghostty.MenuShortcutManager { } } +private extension NSMenu { + var allItems: [NSMenuItem] { + items.flatMap { item in + if let submenu = item.submenu { + return [item] + submenu.allItems + } + + return [item] + } + } +} + +private extension NSMenuItem { + var hasSuppressedSystemMenuKeyEquivalent: Bool { + guard let action else { return false } + + return [ + "hide:", + "hideOtherApplications:", + "miniaturizeAll:", + "performMiniaturize:", + "selectNextTab:", + "selectPreviousTab:", + "showHelp:", + "toggleGhosttyFullScreen:", + "toggleTabOverview:", + ].contains(NSStringFromSelector(action)) + } +} + extension Ghostty.MenuShortcutManager { /// Hashable key for a menu shortcut match, normalized for quick lookup. struct MenuShortcutKey: Hashable { diff --git a/macos/Tests/Ghostty/ConfigTests.swift b/macos/Tests/Ghostty/ConfigTests.swift index a4b8472accd..5785a24cc81 100644 --- a/macos/Tests/Ghostty/ConfigTests.swift +++ b/macos/Tests/Ghostty/ConfigTests.swift @@ -56,6 +56,16 @@ struct ConfigTests { #expect(config.macosWindowShadow == true) } + @Test func macosMenuKeyEquivalentsDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.macosMenuKeyEquivalents == true) + } + + @Test func macosMenuKeyEquivalentsSetToFalse() throws { + let config = try TemporaryConfig("macos-menu-key-equivalents = false") + #expect(config.macosMenuKeyEquivalents == false) + } + @Test func maximizeDefaultsToFalse() throws { let config = try TemporaryConfig("") #expect(config.maximize == false) diff --git a/macos/Tests/Ghostty/MenuShortcutManagerTests.swift b/macos/Tests/Ghostty/MenuShortcutManagerTests.swift index ab8806b9b16..46d0803eb9e 100644 --- a/macos/Tests/Ghostty/MenuShortcutManagerTests.swift +++ b/macos/Tests/Ghostty/MenuShortcutManagerTests.swift @@ -47,4 +47,74 @@ struct MenuShortcutManagerTests { #expect(goToLeftItem.keyEquivalent == "h") #expect(goToLeftItem.keyEquivalentModifierMask == .command) } + + @Test func disabledMenuKeyEquivalentsClearShortcut() async throws { + let config = try TemporaryConfig(""" + macos-menu-key-equivalents = false + keybind = super+n=new_window + """) + + let item = NSMenuItem(title: "New Window", action: "newWindow:", keyEquivalent: "n") + item.keyEquivalentModifierMask = .command + + let manager = await Ghostty.MenuShortcutManager() + await manager.reset() + await manager.syncMenuShortcut(config, action: "new_window", menuItem: item) + + #expect(item.keyEquivalent.isEmpty) + #expect(item.keyEquivalentModifierMask.isEmpty) + } + + @Test func disabledMenuKeyEquivalentsClearSystemShortcut() async throws { + let menu = NSMenu() + let hide = NSMenuItem(title: "Hide Ghostty", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + hide.keyEquivalentModifierMask = .command + menu.addItem(hide) + + let regular = NSMenuItem(title: "Other", action: "other:", keyEquivalent: "o") + regular.keyEquivalentModifierMask = .command + menu.addItem(regular) + + let manager = await Ghostty.MenuShortcutManager() + await manager.syncSystemMenuKeyEquivalents(false, menu: menu) + + #expect(hide.keyEquivalent.isEmpty) + #expect(hide.keyEquivalentModifierMask.isEmpty) + #expect(regular.keyEquivalent == "o") + #expect(regular.keyEquivalentModifierMask == .command) + + await manager.syncSystemMenuKeyEquivalents(true, menu: menu) + + #expect(hide.keyEquivalent == "h") + #expect(hide.keyEquivalentModifierMask == .command) + } + + @Test func disabledMenuKeyEquivalentsClearDynamicTabShortcuts() async throws { + let menu = NSMenu() + let next = NSMenuItem(title: "Show Next Tab", action: "selectNextTab:", keyEquivalent: "}") + next.keyEquivalentModifierMask = [.command, .shift] + menu.addItem(next) + + let manager = await Ghostty.MenuShortcutManager() + await manager.syncSystemMenuKeyEquivalents(false, menu: menu) + + #expect(next.keyEquivalent.isEmpty) + #expect(next.keyEquivalentModifierMask.isEmpty) + } + + @Test func disabledMenuKeyEquivalentsClearMinimizeAllShortcut() async throws { + let menu = NSMenu() + let minimizeAll = NSMenuItem( + title: "Minimize All", + action: #selector(NSApplication.miniaturizeAll(_:)), + keyEquivalent: "m") + minimizeAll.keyEquivalentModifierMask = [.command, .option] + menu.addItem(minimizeAll) + + let manager = await Ghostty.MenuShortcutManager() + await manager.syncSystemMenuKeyEquivalents(false, menu: menu) + + #expect(minimizeAll.keyEquivalent.isEmpty) + #expect(minimizeAll.keyEquivalentModifierMask.isEmpty) + } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 5b1d73deb6c..4e6424ca034 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1906,11 +1906,13 @@ keybind: Keybinds = .{}, /// There are other edge case scenarios that may not behave as expected /// but are working as intended the way this feature is designed: /// -/// * On macOS, bindings in the main menu will trigger before any remapping -/// is done. This is because macOS itself handles menu activation and -/// this happens before Ghostty receives the key event. To workaround -/// this, you should unbind the menu items and rebind them using your -/// desired modifier. +/// * On macOS, menu key equivalents are handled by AppKit before Ghostty +/// receives the key event. For example, `key-remap = command=left_alt` +/// does not stop `cmd+n` from opening a new window, because the New Window +/// menu item handles the shortcut before Ghostty can remap it. To let +/// Ghostty receive and remap those events first, set +/// `macos-menu-key-equivalents = false`, or unbind the menu items and rebind +/// them using your desired modifier. /// /// This configuration can be repeated to specify multiple remaps. @"key-remap": KeyRemapSet = .empty, @@ -3327,6 +3329,22 @@ keybind: Keybinds = .{}, /// find false more visually appealing. @"macos-window-shadow": bool = true, +/// If true, Ghostty will use key equivalents for macOS menu items. +/// +/// On macOS, AppKit handles menu key equivalents before Ghostty receives key +/// events. This means a remap such as `key-remap = command=left_alt` still +/// leaves `cmd+n` captured by the New Window menu item before Ghostty can turn +/// it into `alt+n`. +/// +/// Setting this to false clears those menu shortcuts so the key event reaches +/// Ghostty and can be remapped first. This applies to Ghostty actions shown in +/// the main menu and to standard macOS menu items that Ghostty can identify, +/// such as Hide, Hide Others, Minimize, Toggle Full Screen, Show All Tabs, Show +/// Previous Tab, Show Next Tab, and Ghostty Help. +/// +/// The default is true. +@"macos-menu-key-equivalents": bool = true, + /// If true, the macOS icon in the dock and app switcher will be hidden. This is /// mainly intended for those primarily using the quick-terminal mode. ///