mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
macos: only run key equivalents for Ghostty-owned menu items
Fixes #11396 Track menu items populated from Ghostty keybind actions and only trigger those from SurfaceView performKeyEquivalent. This avoids app-default shortcuts such as Hide from pre-empting explicit keybinds.
This commit is contained in:
@@ -154,6 +154,13 @@ class AppDelegate: NSObject,
|
||||
/// The custom app icon image that is currently in use.
|
||||
@Published private(set) var appIcon: NSImage?
|
||||
|
||||
/// Ghostty menu items indexed by their normalized shortcut. This avoids traversing
|
||||
/// the entire menu tree on every key equivalent event.
|
||||
///
|
||||
/// We store a weak reference so this cache can never be the owner of menu items.
|
||||
/// If multiple items map to the same shortcut, the most recent one wins.
|
||||
private var menuItemsByShortcut: [MenuShortcutKey: Weak<NSMenuItem>] = [:]
|
||||
|
||||
override init() {
|
||||
#if DEBUG
|
||||
ghostty = Ghostty.App(configPath: ProcessInfo.processInfo.environment["GHOSTTY_CONFIG_PATH"])
|
||||
@@ -516,11 +523,6 @@ class AppDelegate: NSObject,
|
||||
return true
|
||||
}
|
||||
|
||||
/// This is called for the dock right-click menu.
|
||||
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
|
||||
return dockMenu
|
||||
}
|
||||
|
||||
/// Setup signal handlers
|
||||
private func setupSignals() {
|
||||
// Register a signal handler for config reloading. It appears that all
|
||||
@@ -549,134 +551,6 @@ class AppDelegate: NSObject,
|
||||
signals.append(sigusr2)
|
||||
}
|
||||
|
||||
/// Setup all the images for our menu items.
|
||||
private func setupMenuImages() {
|
||||
// Note: This COULD Be done all in the xib file, but I find it easier to
|
||||
// modify this stuff as code.
|
||||
self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle")
|
||||
self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down")
|
||||
self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear")
|
||||
self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90")
|
||||
self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display")
|
||||
self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus")
|
||||
self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow")
|
||||
self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled")
|
||||
self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled")
|
||||
self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled")
|
||||
self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled")
|
||||
self.menuClose?.setImageIfDesired(systemSymbolName: "xmark")
|
||||
self.menuPasteSelection?.setImageIfDesired(systemSymbolName: "doc.on.clipboard.fill")
|
||||
self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger")
|
||||
self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size")
|
||||
self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller")
|
||||
self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection")
|
||||
self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal")
|
||||
self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
|
||||
self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope")
|
||||
self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill")
|
||||
self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill")
|
||||
self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward")
|
||||
self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye")
|
||||
self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right")
|
||||
self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2")
|
||||
self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2")
|
||||
self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle")
|
||||
self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left")
|
||||
self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right")
|
||||
self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up")
|
||||
self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down")
|
||||
self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line")
|
||||
self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line")
|
||||
self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line")
|
||||
self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line")
|
||||
self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square")
|
||||
self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass")
|
||||
}
|
||||
|
||||
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
|
||||
private func syncMenuShortcuts(_ config: Ghostty.Config) {
|
||||
guard ghostty.readiness == .ready else { return }
|
||||
|
||||
syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates)
|
||||
syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig)
|
||||
syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig)
|
||||
syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit)
|
||||
|
||||
syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow)
|
||||
syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab)
|
||||
syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose)
|
||||
syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab)
|
||||
syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow)
|
||||
syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows)
|
||||
syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight)
|
||||
syncMenuShortcut(config, action: "new_split:left", menuItem: self.menuSplitLeft)
|
||||
syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
|
||||
syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp)
|
||||
|
||||
syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo)
|
||||
syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo)
|
||||
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
|
||||
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
|
||||
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
|
||||
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
|
||||
syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind)
|
||||
syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind)
|
||||
syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection)
|
||||
syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext)
|
||||
syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious)
|
||||
|
||||
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
|
||||
syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit)
|
||||
syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit)
|
||||
syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove)
|
||||
syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow)
|
||||
syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
|
||||
syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
|
||||
syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
|
||||
syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown)
|
||||
syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight)
|
||||
syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft)
|
||||
syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits)
|
||||
syncMenuShortcut(config, action: "reset_window_size", menuItem: self.menuReturnToDefaultSize)
|
||||
|
||||
syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
|
||||
syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
|
||||
syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize)
|
||||
syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle)
|
||||
syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle)
|
||||
syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
|
||||
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
|
||||
syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop)
|
||||
syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector)
|
||||
syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette)
|
||||
|
||||
syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput)
|
||||
|
||||
// This menu item is NOT synced with the configuration because it disables macOS
|
||||
// global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue
|
||||
// to work but it won't be reflected in the menu item.
|
||||
//
|
||||
// syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
|
||||
|
||||
// Dock menu
|
||||
reloadDockMenu()
|
||||
}
|
||||
|
||||
/// Syncs a single menu shortcut for the given action. The action string is the same
|
||||
/// action string used for the Ghostty configuration.
|
||||
private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) {
|
||||
guard let menu = menuItem else { return }
|
||||
guard let shortcut = config.keyboardShortcut(for: action) else {
|
||||
// No shortcut, clear the menu item
|
||||
menu.keyEquivalent = ""
|
||||
menu.keyEquivalentModifierMask = []
|
||||
return
|
||||
}
|
||||
|
||||
menu.keyEquivalent = shortcut.key.character.description
|
||||
menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers)
|
||||
}
|
||||
|
||||
// MARK: Notifications and Events
|
||||
|
||||
/// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get
|
||||
@@ -1038,17 +912,6 @@ class AppDelegate: NSObject,
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Dock Menu
|
||||
|
||||
private func reloadDockMenu() {
|
||||
let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "")
|
||||
let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "")
|
||||
|
||||
dockMenu.removeAllItems()
|
||||
dockMenu.addItem(newWindow)
|
||||
dockMenu.addItem(newTab)
|
||||
}
|
||||
|
||||
// MARK: - Global State
|
||||
|
||||
func setSecureInput(_ mode: Ghostty.SetSecureInput) {
|
||||
@@ -1211,6 +1074,233 @@ class AppDelegate: NSObject,
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Menu
|
||||
|
||||
extension AppDelegate {
|
||||
/// This is called for the dock right-click menu.
|
||||
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
|
||||
return dockMenu
|
||||
}
|
||||
|
||||
private func reloadDockMenu() {
|
||||
let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "")
|
||||
let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "")
|
||||
|
||||
dockMenu.removeAllItems()
|
||||
dockMenu.addItem(newWindow)
|
||||
dockMenu.addItem(newTab)
|
||||
}
|
||||
|
||||
/// Setup all the images for our menu items.
|
||||
private func setupMenuImages() {
|
||||
// Note: This COULD Be done all in the xib file, but I find it easier to
|
||||
// modify this stuff as code.
|
||||
self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle")
|
||||
self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down")
|
||||
self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear")
|
||||
self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90")
|
||||
self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display")
|
||||
self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus")
|
||||
self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow")
|
||||
self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled")
|
||||
self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled")
|
||||
self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled")
|
||||
self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled")
|
||||
self.menuClose?.setImageIfDesired(systemSymbolName: "xmark")
|
||||
self.menuPasteSelection?.setImageIfDesired(systemSymbolName: "doc.on.clipboard.fill")
|
||||
self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger")
|
||||
self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size")
|
||||
self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller")
|
||||
self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection")
|
||||
self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal")
|
||||
self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
|
||||
self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope")
|
||||
self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill")
|
||||
self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill")
|
||||
self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward")
|
||||
self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye")
|
||||
self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right")
|
||||
self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2")
|
||||
self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2")
|
||||
self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle")
|
||||
self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left")
|
||||
self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right")
|
||||
self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up")
|
||||
self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down")
|
||||
self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line")
|
||||
self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line")
|
||||
self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line")
|
||||
self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line")
|
||||
self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square")
|
||||
self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass")
|
||||
}
|
||||
|
||||
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
|
||||
private func syncMenuShortcuts(_ config: Ghostty.Config) {
|
||||
guard ghostty.readiness == .ready else { return }
|
||||
|
||||
// Reset our shortcut index since we're about to rebuild all menu bindings.
|
||||
menuItemsByShortcut.removeAll(keepingCapacity: true)
|
||||
|
||||
syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates)
|
||||
syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig)
|
||||
syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig)
|
||||
syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit)
|
||||
|
||||
syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow)
|
||||
syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab)
|
||||
syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose)
|
||||
syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab)
|
||||
syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow)
|
||||
syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows)
|
||||
syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight)
|
||||
syncMenuShortcut(config, action: "new_split:left", menuItem: self.menuSplitLeft)
|
||||
syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
|
||||
syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp)
|
||||
|
||||
syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo)
|
||||
syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo)
|
||||
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
|
||||
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
|
||||
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
|
||||
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
|
||||
syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind)
|
||||
syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind)
|
||||
syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection)
|
||||
syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext)
|
||||
syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious)
|
||||
|
||||
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
|
||||
syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit)
|
||||
syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit)
|
||||
syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove)
|
||||
syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow)
|
||||
syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
|
||||
syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
|
||||
syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
|
||||
syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown)
|
||||
syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight)
|
||||
syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft)
|
||||
syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits)
|
||||
syncMenuShortcut(config, action: "reset_window_size", menuItem: self.menuReturnToDefaultSize)
|
||||
|
||||
syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
|
||||
syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
|
||||
syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize)
|
||||
syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle)
|
||||
syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle)
|
||||
syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
|
||||
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
|
||||
syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop)
|
||||
syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector)
|
||||
syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette)
|
||||
|
||||
syncMenuShortcut(config, action: "toggle_secure_input", menuItem: self.menuSecureInput)
|
||||
|
||||
// This menu item is NOT synced with the configuration because it disables macOS
|
||||
// global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue
|
||||
// to work but it won't be reflected in the menu item.
|
||||
//
|
||||
// syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
|
||||
|
||||
// Dock menu
|
||||
reloadDockMenu()
|
||||
}
|
||||
|
||||
/// Syncs a single menu shortcut for the given action. The action string is the same
|
||||
/// action string used for the Ghostty configuration.
|
||||
private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) {
|
||||
guard let menu = menuItem else { return }
|
||||
|
||||
guard let shortcut = config.keyboardShortcut(for: action) else {
|
||||
// No shortcut, clear the menu item
|
||||
menu.keyEquivalent = ""
|
||||
menu.keyEquivalentModifierMask = []
|
||||
return
|
||||
}
|
||||
|
||||
let keyEquivalent = shortcut.key.character.description
|
||||
let modifierMask = NSEvent.ModifierFlags(swiftUIFlags: shortcut.modifiers)
|
||||
menu.keyEquivalent = keyEquivalent
|
||||
menu.keyEquivalentModifierMask = modifierMask
|
||||
|
||||
// Build a direct lookup for key-equivalent dispatch so we don't need to
|
||||
// linearly walk the full menu hierarchy at event time.
|
||||
guard let key = MenuShortcutKey(
|
||||
keyEquivalent: keyEquivalent,
|
||||
modifiers: modifierMask
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Later registrations intentionally override earlier ones for the same key.
|
||||
menuItemsByShortcut[key] = .init(menu)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// like Cmd+H.
|
||||
func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
// Convert this event into the same normalized lookup key we use when
|
||||
// syncing menu shortcuts from configuration.
|
||||
guard let key = MenuShortcutKey(event: event) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we don't have an entry for this key combo, no Ghostty-owned
|
||||
// menu shortcut exists for this event.
|
||||
guard let weakItem = menuItemsByShortcut[key] else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Weak references can be nil if a menu item was deallocated after sync.
|
||||
guard let item = weakItem.value else {
|
||||
menuItemsByShortcut.removeValue(forKey: key)
|
||||
return false
|
||||
}
|
||||
|
||||
guard let parentMenu = item.menu else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Keep enablement state fresh in case menu validation hasn't run yet.
|
||||
parentMenu.update()
|
||||
guard item.isEnabled else {
|
||||
return false
|
||||
}
|
||||
|
||||
let index = parentMenu.index(of: item)
|
||||
guard index >= 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
parentMenu.performActionForItem(at: index)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Hashable key for a menu shortcut match, normalized for quick lookup.
|
||||
private struct MenuShortcutKey: Hashable {
|
||||
private static let shortcutModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command]
|
||||
|
||||
private let keyEquivalent: String
|
||||
private let modifiersRawValue: UInt
|
||||
|
||||
init?(keyEquivalent: String, modifiers: NSEvent.ModifierFlags) {
|
||||
let normalized = keyEquivalent.lowercased()
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
|
||||
self.keyEquivalent = normalized
|
||||
self.modifiersRawValue = modifiers.intersection(Self.shortcutModifiers).rawValue
|
||||
}
|
||||
|
||||
init?(event: NSEvent) {
|
||||
guard let keyEquivalent = event.charactersIgnoringModifiers else { return nil }
|
||||
self.init(keyEquivalent: keyEquivalent, modifiers: event.modifierFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Floating Windows
|
||||
|
||||
extension AppDelegate {
|
||||
|
||||
@@ -1265,7 +1265,8 @@ extension Ghostty {
|
||||
keyTables.isEmpty,
|
||||
bindingFlags.isDisjoint(with: [.all, .performable]),
|
||||
bindingFlags.contains(.consumed) {
|
||||
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
||||
if let appDelegate = NSApp.delegate as? AppDelegate,
|
||||
appDelegate.performGhosttyBindingMenuKeyEquivalent(with: event) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user