pull/12522/merge
Lukas 2026-06-02 21:56:29 -07:00 committed by GitHub
commit 79c93fb91e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 105 additions and 14 deletions

View File

@ -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
}

View File

@ -25,6 +25,7 @@
<outlet property="menuCloseWindow" destination="W5w-UZ-crk" id="6ff-BT-ENV"/>
<outlet property="menuCommandPalette" destination="et6-de-Mh7" id="53t-cu-dm5"/>
<outlet property="menuCopy" destination="Jqf-pv-Zcu" id="bKd-1C-oy9"/>
<outlet property="menuCut" destination="H0A-Vb-3cx" id="fby-cu-YhH"/>
<outlet property="menuDecreaseFontSize" destination="kzb-SZ-dOA" id="Y1B-Vh-6Z2"/>
<outlet property="menuEqualizeSplits" destination="3gH-VD-vL9" id="SiZ-ce-FOF"/>
<outlet property="menuFind" destination="nwE-0w-30h" id="idg-Nc-apE"/>
@ -223,27 +224,28 @@
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="iU4-OB-ccf">
<items>
<menuItem title="Undo" id="r83-CV-syt">
<modifierMask key="keyEquivalentModifierMask"/>
<menuItem title="Undo" keyEquivalent="z" id="r83-CV-syt">
<connections>
<action selector="undo:" target="-1" id="jrW-j3-OZj"/>
</connections>
</menuItem>
<menuItem title="Redo" id="EX8-lB-4s7">
<modifierMask key="keyEquivalentModifierMask"/>
<menuItem title="Redo" keyEquivalent="Z" id="EX8-lB-4s7">
<connections>
<action selector="redo:" target="-1" id="7UK-Hj-s4O"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="4O9-zO-zB9"/>
<menuItem title="Copy" id="Jqf-pv-Zcu">
<modifierMask key="keyEquivalentModifierMask"/>
<menuItem title="Cut" keyEquivalent="x" id="H0A-Vb-3cx">
<connections>
<action selector="cut:" target="-1" id="iOR-ZB-8ZI"/>
</connections>
</menuItem>
<menuItem title="Copy" keyEquivalent="c" id="Jqf-pv-Zcu">
<connections>
<action selector="copy:" target="-1" id="B4F-hg-R4T"/>
</connections>
</menuItem>
<menuItem title="Paste" id="i27-pK-umN">
<modifierMask key="keyEquivalentModifierMask"/>
<menuItem title="Paste" keyEquivalent="v" id="i27-pK-umN">
<connections>
<action selector="paste:" target="-1" id="ZKe-2B-mel"/>
</connections>
@ -254,8 +256,7 @@
<action selector="pasteSelection:" target="-1" id="vo3-Rf-Udb"/>
</connections>
</menuItem>
<menuItem title="Select All" id="q2h-lq-e4r">
<modifierMask key="keyEquivalentModifierMask"/>
<menuItem title="Select All" keyEquivalent="a" id="q2h-lq-e4r">
<connections>
<action selector="selectAll:" target="-1" id="0CH-Tp-7Ud"/>
</connections>

View File

@ -13,6 +13,46 @@ extension Ghostty {
/// If multiple items map to the same shortcut, the most recent one wins.
private var menuItemsByShortcut: [MenuShortcutKey: Weak<NSMenuItem>] = [:]
/// Original shortcut configured in xib indexed by their action
private var restorableMenuItemsByOriginalShortcut: [MenuShortcutKey: (Weak<NSMenuItem>, String?)] = [:]
private var menuItemsRestored = [(Weak<NSMenuItem>, 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)

View File

@ -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
}