From 699e708601401d8e3cee01bdc1a49e82c0595c7b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:48:29 -0800 Subject: [PATCH] Scope command shortcut hints to active window --- Sources/ContentView.swift | 132 ++++++++++++++++-- Sources/Update/UpdateTitlebarAccessory.swift | 103 ++++++++++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 51 +++++++ 3 files changed, 262 insertions(+), 24 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7e77f333..ec6512bb 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1193,6 +1193,12 @@ struct VerticalTabsSidebar: View { .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .background(SidebarBackdrop().ignoresSafeArea()) + .background( + WindowAccessor { window in + commandKeyMonitor.setHostWindow(window) + } + .frame(width: 0, height: 0) + ) .onAppear { commandKeyMonitor.start() draggedTabId = nil @@ -1253,6 +1259,35 @@ enum SidebarCommandHintPolicy { static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool { modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] } + + static func isCurrentWindow( + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int? + ) -> Bool { + guard let hostWindowNumber, hostWindowIsKey else { return false } + if let eventWindowNumber { + return eventWindowNumber == hostWindowNumber + } + return keyWindowNumber == hostWindowNumber + } + + static func shouldShowHints( + for modifierFlags: NSEvent.ModifierFlags, + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int? + ) -> Bool { + shouldShowHints(for: modifierFlags) && + isCurrentWindow( + hostWindowNumber: hostWindowNumber, + hostWindowIsKey: hostWindowIsKey, + eventWindowNumber: eventWindowNumber, + keyWindowNumber: keyWindowNumber + ) + } } enum ShortcutHintDebugSettings { @@ -1484,28 +1519,63 @@ private struct SidebarExternalDropDelegate: DropDelegate { private final class SidebarCommandKeyMonitor: ObservableObject { @Published private(set) var isCommandPressed = false + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? private var flagsMonitor: Any? private var keyDownMonitor: Any? - private var resignObserver: NSObjectProtocol? + private var appResignObserver: NSObjectProtocol? private var pendingShowWorkItem: DispatchWorkItem? + func setHostWindow(_ window: NSWindow?) { + guard hostWindow !== window else { return } + removeHostWindowObservers() + hostWindow = window + guard let window else { + cancelPendingHintShow(resetVisible: true) + return + } + + hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.update(from: NSEvent.modifierFlags, eventWindow: nil) + } + } + + hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + func start() { guard flagsMonitor == nil else { - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) return } flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - self?.update(from: event.modifierFlags) + self?.update(from: event.modifierFlags, eventWindow: event.window) return event } keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - self?.cancelPendingHintShow(resetVisible: true) + self?.handleKeyDown(event) return event } - resignObserver = NotificationCenter.default.addObserver( + appResignObserver = NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main @@ -1515,7 +1585,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } } - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) } func stop() { @@ -1527,15 +1597,36 @@ private final class SidebarCommandKeyMonitor: ObservableObject { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } - if let resignObserver { - NotificationCenter.default.removeObserver(resignObserver) - self.resignObserver = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil } + removeHostWindowObservers() cancelPendingHintShow(resetVisible: true) } - private func update(from modifierFlags: NSEvent.ModifierFlags) { - guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else { + private func handleKeyDown(_ event: NSEvent) { + guard isCurrentWindow(eventWindow: event.window) else { return } + cancelPendingHintShow(resetVisible: true) + } + + private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) + } + + private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { + guard SidebarCommandHintPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { cancelPendingHintShow(resetVisible: true) return } @@ -1550,7 +1641,13 @@ private final class SidebarCommandKeyMonitor: ObservableObject { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return } + guard SidebarCommandHintPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { return } self.isCommandPressed = true } @@ -1565,6 +1662,17 @@ private final class SidebarCommandKeyMonitor: ObservableObject { isCommandPressed = false } } + + private func removeHostWindowObservers() { + if let hostWindowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) + self.hostWindowDidBecomeKeyObserver = nil + } + if let hostWindowDidResignKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) + self.hostWindowDidResignKeyObserver = nil + } + } } #if DEBUG diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 5c96e1be..ff73c91a 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -276,6 +276,12 @@ struct TitlebarControlsView: View { controlsGroup(config: config) .padding(.leading, 4) .padding(.trailing, titlebarHintTrailingInset) + .background( + WindowAccessor { window in + commandKeyMonitor.setHostWindow(window) + } + .frame(width: 0, height: 0) + ) .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in shortcutRefreshTick &+= 1 } @@ -495,28 +501,63 @@ struct TitlebarControlsView: View { private final class TitlebarCommandKeyMonitor: ObservableObject { @Published private(set) var isCommandPressed = false + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? private var flagsMonitor: Any? private var keyDownMonitor: Any? - private var resignObserver: NSObjectProtocol? + private var appResignObserver: NSObjectProtocol? private var pendingShowWorkItem: DispatchWorkItem? + func setHostWindow(_ window: NSWindow?) { + guard hostWindow !== window else { return } + removeHostWindowObservers() + hostWindow = window + guard let window else { + cancelPendingHintShow(resetVisible: true) + return + } + + hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.update(from: NSEvent.modifierFlags, eventWindow: nil) + } + } + + hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + func start() { guard flagsMonitor == nil else { - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) return } flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - self?.update(from: event.modifierFlags) + self?.update(from: event.modifierFlags, eventWindow: event.window) return event } keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - self?.cancelPendingHintShow(resetVisible: true) + self?.handleKeyDown(event) return event } - resignObserver = NotificationCenter.default.addObserver( + appResignObserver = NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main @@ -526,7 +567,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } } - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) } func stop() { @@ -538,15 +579,36 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } - if let resignObserver { - NotificationCenter.default.removeObserver(resignObserver) - self.resignObserver = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil } + removeHostWindowObservers() cancelPendingHintShow(resetVisible: true) } - private func update(from modifierFlags: NSEvent.ModifierFlags) { - guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else { + private func handleKeyDown(_ event: NSEvent) { + guard isCurrentWindow(eventWindow: event.window) else { return } + cancelPendingHintShow(resetVisible: true) + } + + private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) + } + + private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { + guard SidebarCommandHintPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { cancelPendingHintShow(resetVisible: true) return } @@ -561,7 +623,13 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return } + guard SidebarCommandHintPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { return } self.isCommandPressed = true } @@ -576,6 +644,17 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { isCommandPressed = false } } + + private func removeHostWindowObservers() { + if let hostWindowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) + self.hostWindowDidBecomeKeyObserver = nil + } + if let hostWindowDidResignKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) + self.hostWindowDidResignKeyObserver = nil + } + } } final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index f4ddbf21..2de3d45c 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -500,6 +500,57 @@ final class SidebarCommandHintPolicyTests: XCTestCase { func testCommandHintUsesIntentionalHoldDelay() { XCTAssertGreaterThanOrEqual(SidebarCommandHintPolicy.intentionalHoldDelay, 0.25) } + + func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() { + XCTAssertTrue( + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 42, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 7, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: false, + eventWindowNumber: 42, + keyWindowNumber: 42 + ) + ) + } + + func testWindowScopedCommandHintsUseKeyWindowWhenNoEventWindowIsAvailable() { + XCTAssertTrue( + SidebarCommandHintPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + SidebarCommandHintPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 7 + ) + ) + } } final class ShortcutHintDebugSettingsTests: XCTestCase {