diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 9700a31ae..05d516811 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import UserNotifications import OSLog @@ -39,6 +40,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuUndo: NSMenuItem? @IBOutlet private var menuRedo: NSMenuItem? + @IBOutlet private var menuCut: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @@ -154,6 +156,8 @@ class AppDelegate: NSObject, private let appIconUpdater = AppIconUpdater() @MainActor private lazy var menuShortcutManager = Ghostty.MenuShortcutManager() + /// A observer for resetting menu shortcuts based on firstResponder + private var resetMenuObserver: Any? override init() { #if DEBUG @@ -209,6 +213,8 @@ class AppDelegate: NSObject, toggleSecureInput(self) } + setupRestorableMenuItems() + // Initial config loading ghosttyConfigDidChange(config: ghostty.config) @@ -1128,6 +1134,50 @@ extension AppDelegate { self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") } + /// Save restorable menu items and restore them whenever the first responder is not a terminal surface + /// + /// If you plan to add more items here, make sure you add the default shortcut in MainMenu.xib + @MainActor private func setupRestorableMenuItems() { + [ + (menuUndo, "undo"), + (menuRedo, "redo"), + (menuCut, nil), + (menuCopy, "copy_to_clipboard"), + (menuPaste, "paste_from_clipboard"), + (menuSelectAll, "select_all"), + ].forEach(menuShortcutManager.saveRestorableMenuItem(_:action:)) + + // The reason we're using publisher here instead of `windowDidBecomeKey` + // is that we want reset the observer whenever the key window is changed. + // + // I had a case where help menu's window becomes key with terminal window opened then moving away from help menu doesn't trigger another notification. + resetMenuObserver = NSApp.publisher(for: \.keyWindow) + .flatMap { + $0?.publisher(for: \.firstResponder, options: [.new]) + .map { $0 is Ghostty.SurfaceView } + .eraseToAnyPublisher() ?? + Just(true).eraseToAnyPublisher() + // When the keyWindow is nil, we want to re-sync them + } + .removeDuplicates() + .sink { [weak self] isSurfaceFocused in + guard let self else { return } + if isSurfaceFocused { + menuShortcutManager.reSyncRestoredMenuShortcuts(config: ghostty.config) + } else { + // Restore for non terminal responders, like: + // 1. About Window + // 2. Alert modal + // 3. ConfigurationErrors + // 4. InlineTitleEditor + // 5. SearchOverlay + // 6. CommandPalette + // 7. Help search + menuShortcutManager.restoreMenuShortcuts() + } + } + } + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. @MainActor private func syncMenuShortcuts(_ config: Ghostty.Config) { guard ghostty.readiness == .ready else { return } @@ -1152,6 +1202,7 @@ extension AppDelegate { syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) + syncMenuShortcut(config, action: nil, menuItem: self.menuCut) 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) @@ -1200,7 +1251,7 @@ extension AppDelegate { reloadDockMenu() } - @MainActor private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) { + @MainActor private func syncMenuShortcut(_ config: Ghostty.Config, action: String?, menuItem: NSMenuItem?) { menuShortcutManager.syncMenuShortcut(config, action: action, menuItem: menuItem) } @@ -1285,7 +1336,8 @@ extension AppDelegate: NSMenuItemValidation { item.title = "Redo" } return undoManager.canRedo - + case menuCut?.action: + return false default: return true } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 30cd985db..2ec221ba7 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -25,6 +25,7 @@ + @@ -223,27 +224,28 @@ - - + - - + - - + + + + + + - - + @@ -254,8 +256,7 @@ - - + diff --git a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift index d7145745f..6bbfa0f51 100644 --- a/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift +++ b/macos/Sources/Ghostty/Ghostty.MenuShortcutManager.swift @@ -13,6 +13,46 @@ extension Ghostty { /// If multiple items map to the same shortcut, the most recent one wins. private var menuItemsByShortcut: [MenuShortcutKey: Weak] = [:] + /// Original shortcut configured in xib indexed by their action + private var restorableMenuItemsByOriginalShortcut: [MenuShortcutKey: (Weak, String?)] = [:] + + private var menuItemsRestored = [(Weak, String?)]() + + func saveRestorableMenuItem(_ item: NSMenuItem?, action ghosttyAction: String?) { + guard + let item, + let key = MenuShortcutKey(item) + else { + return + } + // Later registrations intentionally override earlier ones for the same key. + restorableMenuItemsByOriginalShortcut[key] = (.init(item), ghosttyAction) + } + + /// Restore shortcuts for the items that are registered + /// + /// - Important: the item is only restored when the current shortcut is empty + func restoreMenuShortcuts() { + menuItemsRestored.removeAll() + for (key, item) in restorableMenuItemsByOriginalShortcut { + if let menuItem = item.0.value, menuItem.keyEquivalent.isEmpty { + menuItem.keyEquivalent = key.keyEquivalent + menuItem.keyEquivalentModifierMask = key.modifierFlags + menuItemsRestored.append(item) + } + } + } + + /// Re-sync shortcuts for the items that are restored + func reSyncRestoredMenuShortcuts(config: Ghostty.Config) { + for item in menuItemsRestored { + if let menuItem = item.0.value { + syncMenuShortcut(config, action: item.1, menuItem: menuItem) + } + } + menuItemsRestored.removeAll() + } + /// Reset our shortcut index since we're about to rebuild all menu bindings. func reset() { menuItemsByShortcut.removeAll(keepingCapacity: true) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 8c22c0cdf..e22f0b08c 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -791,11 +791,9 @@ extension Ghostty { override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() - // We sometimes call this manually (see SplitView) as a way to force us to // yield our focus state. if result { focusDidChange(false) } - return result }