Scope command shortcut hints to active window

This commit is contained in:
Lawrence Chen 2026-02-20 18:48:29 -08:00
parent d9b7511b07
commit 699e708601
3 changed files with 262 additions and 24 deletions

View file

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

View file

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

View file

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