Skip to content
Closed
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
13 changes: 13 additions & 0 deletions macos/Sources/App/macOS/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -1209,6 +1214,8 @@ extension AppDelegate {
//
// syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)

menuShortcutManager.syncSystemMenuKeyEquivalents(config, menu: NSApp.mainMenu)

// Dock menu
reloadDockMenu()
}
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions macos/Sources/Ghostty/Ghostty.Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
76 changes: 75 additions & 1 deletion macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ extension Ghostty {
/// If multiple items map to the same shortcut, the most recent one wins.
private var menuItemsByShortcut: [MenuShortcutKey: Weak<NSMenuItem>] = [:]

private var systemMenuShortcuts: [ObjectIdentifier: StoredShortcut] = [:]

/// Reset our shortcut index since we're about to rebuild all menu bindings.
func reset() {
menuItemsByShortcut.removeAll(keepingCapacity: true)
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions macos/Tests/Ghostty/ConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions macos/Tests/Ghostty/MenuShortcutManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
28 changes: 23 additions & 5 deletions src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
///
Expand Down
Loading