diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 7dd8932c..1502f25d 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -18,10 +18,12 @@ BF0C8477850829E15FD4DD43 /* close@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8F9CE1699644A22EAF2C /* close@2x.png */; }; BF0C852D28E458CDD6FA3E44 /* app.icns in Resources */ = {isa = PBXBuildFile; fileRef = BF0C84E11784B3B0900D0963 /* app.icns */; }; BF0C853B271C4407E87FA183 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8096A88BC18C4902C037 /* InfoPlist.strings */; }; + BF0C855CF8BA047E180425F9 /* KeyRepeatTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C864F182B6155A66D241D /* KeyRepeatTimer.swift */; }; BF0C8658CC748011E57691B7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C84F4B25A7C26A88ADE92 /* Localizable.strings */; }; BF0C89DF22C7B7D0C27DA2A4 /* WindowControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C884157B87BF0E17EF7F8 /* WindowControlView.swift */; }; BF0C8C9CA44285E493918A74 /* menubar-3@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8E54658B4DAEA0528200 /* menubar-3@2x.png */; }; BF0C8CB668FFABBCA23880B6 /* minimize@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = BF0C817D9C335EE4A0536601 /* minimize@2x.png */; }; + BF0C8CC5057406014FD612CC /* ATShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8BA452332236D972C60E /* ATShortcut.swift */; }; BF0C8F80281B50D57C95ECCF /* fullscreen@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = BF0C83BD867F6A874F9953C9 /* fullscreen@2x.png */; }; BF0C8FA2D756D3258E3A16B6 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C81DF55C6B0A279CE0FE1 /* Localizable.strings */; }; D04BA004884A273D4D2D3EF1 /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD91161791D42FEC4A60 /* HelperExtensions.swift */; }; @@ -69,7 +71,7 @@ D04BA737008AA2CD4E230A21 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA10777505D8A67ABD186 /* Application.swift */; }; D04BA73E90EFEF8247A5105D /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */; }; D04BA76A74267B1346D23687 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA6D57A1456C07318B8EA /* GridView.swift */; }; - D04BA775CF3F8D9394A1E256 /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA68C2561D9EE4FD851B8 /* Screen.swift */; }; + D04BA775CF3F8D9394A1E256 /* NSScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA68C2561D9EE4FD851B8 /* NSScreen.swift */; }; D04BA7B8D599E1A7A27FF5AE /* AcknowledgmentsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA788228BA86D9EFBD1ED /* AcknowledgmentsTab.swift */; }; D04BA7B9421871EDBD58F4E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BABA255CD25D8724A06CD /* Localizable.strings */; }; D04BA7BE7F3DD24D58ACE942 /* AppearanceTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA64F1F344007EA13BA05 /* AppearanceTab.swift */; }; @@ -141,12 +143,14 @@ BF0C8465A0A9EA2814A22C35 /* SF-Pro-Text-Regular-Full.otf */ = {isa = PBXFileReference; lastKnownFileType = file.otf; path = "SF-Pro-Text-Regular-Full.otf"; sourceTree = ""; }; BF0C84E11784B3B0900D0963 /* app.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = app.icns; sourceTree = ""; }; BF0C86370093A080DD5974D8 /* convert_svg_to_png.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = convert_svg_to_png.sh; sourceTree = ""; }; + BF0C864F182B6155A66D241D /* KeyRepeatTimer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyRepeatTimer.swift; sourceTree = ""; }; BF0C87B913292EE53EDE4220 /* menubar-1.svg */ = {isa = PBXFileReference; lastKnownFileType = file.svg; path = "menubar-1.svg"; sourceTree = ""; }; BF0C884157B87BF0E17EF7F8 /* WindowControlView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WindowControlView.swift; sourceTree = ""; }; BF0C889529FF19620EDAC295 /* accessibility@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "accessibility@2x.png"; sourceTree = ""; }; BF0C88BF67CF4D71002A60EB /* menubar-1@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-1@2x.png"; sourceTree = ""; }; BF0C88D6043B83B535BEAAA2 /* no */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = no; path = Localizable.strings; sourceTree = ""; }; BF0C899605B39AB4EE0E8EB8 /* icon_512x512.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = icon_512x512.png; sourceTree = ""; }; + BF0C8BA452332236D972C60E /* ATShortcut.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ATShortcut.swift; sourceTree = ""; }; BF0C8D3333653ACAEF730DD8 /* menubar-2@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-2@2x.png"; sourceTree = ""; }; BF0C8E25373671B528AD448D /* screen-recording@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "screen-recording@2x.png"; sourceTree = ""; }; BF0C8E54658B4DAEA0528200 /* menubar-3@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-3@2x.png"; sourceTree = ""; }; @@ -234,7 +238,7 @@ D04BA653BD073CB58E2CFC93 /* ThumbnailsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsPanel.swift; sourceTree = ""; }; D04BA662DF59DF9A97FDA598 /* de */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = de; path = Localizable.strings; sourceTree = ""; }; D04BA67D904743A293768CB1 /* frontpage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = frontpage.jpg; sourceTree = ""; }; - D04BA68C2561D9EE4FD851B8 /* Screen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; }; + D04BA68C2561D9EE4FD851B8 /* NSScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSScreen.swift; sourceTree = ""; }; D04BA6BBFD0E87B8458E91D3 /* feature-suggestion.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = "feature-suggestion.md"; sourceTree = ""; }; D04BA6D57A1456C07318B8EA /* GridView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = ""; }; D04BA6ED38651224DB117A5A /* upload_symbols_to_appcenter.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = upload_symbols_to_appcenter.sh; sourceTree = ""; }; @@ -461,7 +465,7 @@ children = ( D04BACD976030676FD0761D5 /* Windows.swift */, D04BA9B93823398A542FF7A0 /* Preferences.swift */, - D04BA68C2561D9EE4FD851B8 /* Screen.swift */, + D04BA68C2561D9EE4FD851B8 /* NSScreen.swift */, D04BA7C6F2519091717F4B4E /* SystemPermissions.swift */, D04BA7CF9C2D1BEC7C05AB24 /* Spaces.swift */, D04BA015A45DE7AFDC9794FE /* Window.swift */, @@ -471,6 +475,8 @@ D04BACABD048E62EBE4576CC /* DebugProfile.swift */, D04BAC8857A527C2E15D6598 /* events */, D04BA7E3EA104941FE76DA70 /* AppCenterCrashes.swift */, + BF0C864F182B6155A66D241D /* KeyRepeatTimer.swift */, + BF0C8BA452332236D972C60E /* ATShortcut.swift */, ); path = logic; sourceTree = ""; @@ -1242,7 +1248,7 @@ D04BA8480A8FF466CA89DA5B /* main.swift in Sources */, D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */, D04BAC011A71E0418154F8CD /* Preferences.swift in Sources */, - D04BA775CF3F8D9394A1E256 /* Screen.swift in Sources */, + D04BA775CF3F8D9394A1E256 /* NSScreen.swift in Sources */, D04BADCDA9F9A6C3D6499877 /* SystemPermissions.swift in Sources */, D04BABEECBC6D922298BC93A /* Spaces.swift in Sources */, D04BA6187A91A847844B6ABB /* Window.swift in Sources */, @@ -1287,6 +1293,8 @@ D04BAA16F21AC41A23DDA63D /* ControlsTab.swift in Sources */, D04BADE4DDF7597749FB13BD /* PreferencesWindow.swift in Sources */, BF0C89DF22C7B7D0C27DA2A4 /* WindowControlView.swift in Sources */, + BF0C855CF8BA047E180425F9 /* KeyRepeatTimer.swift in Sources */, + BF0C8CC5057406014FD612CC /* ATShortcut.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/docs/Contributing.md b/docs/Contributing.md index 8b7f9c2a..dec9c3ee 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -136,7 +136,11 @@ In an attempt to not have too many regressions, this documents will list OS inte * Shortcuts can include the `escape` and `delete` key; these should not stop recording shortcuts * [Secure Input](https://github.com/lwouis/alt-tab-macos/issues/157#issuecomment-659170293) can prevent AltTab from listening to the keyboard * Some shortcuts should only work when AltTab is open + * These shortcuts should active whether the hold shortcut is held or not * Shortcuts should work with capslock active or inactive +* Shortcuts should repeat if kept pressed + * Repeat rate and initial delay should match the values set in `System Preference` > `Keyboard` + * when navigating left/right/up/down, the repeating behavior should stop when hitting the last window in the list. The user can then manually do the shortcut once more to cycle to the other side; it then repeats again * The shortcut sets 1 and 2 should not interact with each other (e.g. opening AltTab with one, then using the other to navigate) * Shortcuts can focus the window on release, or be pressing a key or using the mouse * Keyboards from other countries have different layout which impact shortcuts diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index 3e52d4e2..240c38e4 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -1,5 +1,27 @@ import Cocoa +// if the window server is busy, it may not reply to AX calls. We retry right before the call times-out and returns a bogus value +func retryAxCallUntilTimeout(_ group: DispatchGroup? = nil, _ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) { + group?.enter() + BackgroundWork.axCallsQueue.async { + retryAxCallUntilTimeout_(group, fn, startTime) + } +} + +func retryAxCallUntilTimeout_(_ group: DispatchGroup?, _ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) { + do { + try fn() + group?.leave() + } catch { + let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000 + if timePassedInSeconds < Double(AXUIElement.globalTimeoutInSeconds) { + BackgroundWork.axCallsQueue.asyncAfter(deadline: .now() + .milliseconds(10)) { + retryAxCallUntilTimeout_(group, fn, startTime) + } + } + } +} + extension AXUIElement { static let globalTimeoutInSeconds = Float(120) diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index cc683dec..071a6e94 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -146,7 +146,7 @@ extension NSImage { func tinted(_ tint: NSColor) -> NSImage { let dimmed = copy() as! NSImage - let scaling = Screen.withMouse()?.backingScaleFactor ?? 1 + let scaling = NSScreen.withMouse()?.backingScaleFactor ?? 1 let scaledSize = NSSize(width: (size.width * scaling).rounded(), height: (size.height * scaling).rounded()) dimmed.size = scaledSize dimmed.lockFocus() diff --git a/src/logic/ATShortcut.swift b/src/logic/ATShortcut.swift new file mode 100644 index 00000000..58411d22 --- /dev/null +++ b/src/logic/ATShortcut.swift @@ -0,0 +1,102 @@ +import Cocoa +import ShortcutRecorder + +class ATShortcut { + var shortcut: Shortcut + var id: String + var scope: ShortcutScope + var triggerPhase: ShortcutTriggerPhase + var state: ShortcutState = .up + var index: Int? + + init(_ shortcut: Shortcut, _ id: String, _ scope: ShortcutScope, _ triggerPhase: ShortcutTriggerPhase, _ index: Int? = nil) { + self.shortcut = shortcut + self.id = id + self.scope = scope + self.triggerPhase = triggerPhase + self.index = index + } + + func matches(_ id: EventHotKeyID?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: UInt32?, _ isARepeat: Bool) -> Bool { + if let id = id, let shortcutState = shortcutState { + let shortcutIndex = Int(id.id) + let shortcutId = Array(KeyboardEvents.globalShortcutsIds).first { $0.value == shortcutIndex }!.key + if shortcutId == self.id { + state = state == .down ? .up : .down + if state == .up { + KeyRepeatTimer.timer?.invalidate() + } + if (triggerPhase == .down && shortcutState == .down) || (triggerPhase == .up && shortcutState == .up) { + return true + } + } + } + if let modifiers = modifiers { + let modifiersMatch_ = modifiersMatch(modifiers) + let flipped = (state == .up && (shortcut.keyCode == .none || keyCode == shortcut.carbonKeyCode) && modifiersMatch_) || + (state == .down && ((shortcut.keyCode != .none && keyCode != shortcut.carbonKeyCode) || !modifiersMatch_)) + if flipped { + state = state == .down ? .up : .down + if state == .up { + KeyRepeatTimer.timer?.invalidate() + } + } + if (flipped || isARepeat) && ((triggerPhase == .up && state == .up) || (triggerPhase == .down && state == .down)) { + return true + } + } + return false + } + + func modifiersMatch(_ modifiers: UInt32) -> Bool { + if id.hasPrefix("holdShortcut") { + // contains at least + return modifiers == (modifiers | shortcut.carbonModifierFlags) + } + let suffix = App.app.shortcutIndex == 0 ? "" : "2" + let holdModifiers = ControlsTab.shortcuts["nextWindowShortcut" + suffix]!.shortcut.carbonModifierFlags + // contains exactly or exactly + holdShortcut modifiers + return modifiers == shortcut.carbonModifierFlags || modifiers == (shortcut.carbonModifierFlags | holdModifiers) + } + + func shouldTrigger() -> Bool { + if scope == .global { + if triggerPhase == .down && (!App.app.appIsBeingUsed || index == App.app.shortcutIndex) { + App.app.appIsBeingUsed = true + return true + } + if triggerPhase == .up && App.app.appIsBeingUsed && (index == nil || index == App.app.shortcutIndex) && Preferences.shortcutStyle[App.app.shortcutIndex] == .focusOnRelease { + return true + } + } + if scope == .local { + if App.app.appIsBeingUsed && (index == nil || index == App.app.shortcutIndex) { + return true + } + } + return false + } + + func executeAction(_ isARepeat: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + KeyRepeatTimer.isARepeat = isARepeat + ControlsTab.shortcutsActions[self.id]!() + } + } +} + +enum ShortcutTriggerPhase { + case down + case up +} + +enum ShortcutState { + case down + case up +} + +enum ShortcutScope { + case global + case local +} diff --git a/src/logic/BackgroundWork.swift b/src/logic/BackgroundWork.swift index 03db577d..e9bb877c 100644 --- a/src/logic/BackgroundWork.swift +++ b/src/logic/BackgroundWork.swift @@ -9,6 +9,7 @@ class BackgroundWork { static var accessibilityEventsThread: BackgroundThreadWithRunLoop! static var mouseEventsThread: BackgroundThreadWithRunLoop! static var systemPermissionsThread: BackgroundThreadWithRunLoop! + static var repeatingKeyThread: BackgroundThreadWithRunLoop! // we cap concurrent tasks to .processorCount to avoid thread explosion on the .global queue static let globalSemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.processorCount) @@ -23,6 +24,7 @@ class BackgroundWork { crashReportsQueue = DispatchQueue.globalConcurrent("crashReportsQueue", .utility) accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread", .userInteractive) mouseEventsThread = BackgroundThreadWithRunLoop("mouseEventsThread", .userInteractive) + repeatingKeyThread = BackgroundThreadWithRunLoop("repeatingKeyThread", .userInteractive) } static func startSystemPermissionThread() { diff --git a/src/logic/KeyRepeatTimer.swift b/src/logic/KeyRepeatTimer.swift new file mode 100644 index 00000000..6fd75896 --- /dev/null +++ b/src/logic/KeyRepeatTimer.swift @@ -0,0 +1,46 @@ +import Cocoa +import ShortcutRecorder + +class KeyRepeatTimer { + static var timer: Timer? + static var isARepeat = false + + static func toggleRepeatingKeyPreviousWindow() { + let shortcut = ControlsTab.shortcuts["previousWindowShortcut"]! + if shortcut.shortcut.keyCode == .none { + toggleRepeatingKey(shortcut) { + App.app.previousWindowShortcutWithRepeatingKey() + } + } + } + + static func toggleRepeatingKeyNextWindow() { + let suffix = App.app.shortcutIndex == 0 ? "" : "2" + let shortcut = ControlsTab.shortcuts["nextWindowShortcut" + suffix]! + toggleRepeatingKey(shortcut) { + ControlsTab.shortcutsActions["nextWindowShortcut" + suffix]!() + } + } + + private static func toggleRepeatingKey(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) { + if (timer == nil || !timer!.isValid) { + let repeatRate = ticksToSeconds(defaults.string(forKey: "KeyRepeat") ?? "6") + let initialDelay = ticksToSeconds(defaults.string(forKey: "InitialKeyRepeat") ?? "25") + timer = Timer(fire: Date(timeIntervalSinceNow: initialDelay), interval: repeatRate, repeats: true, block: { _ in + if atShortcut.state == .up { + timer?.invalidate() + } else { + DispatchQueue.main.async { + block() + } + } + }) + CFRunLoopAddTimer(BackgroundWork.repeatingKeyThread.runLoop, timer!, .defaultMode) + } + } + + private static func ticksToSeconds(_ appleNumber: String) -> Double { + let refreshRate = NSScreen.preferred().refreshRate() ?? 60 + return Double(appleNumber)! / refreshRate + } +} diff --git a/src/logic/Screen.swift b/src/logic/NSScreen.swift similarity index 68% rename from src/logic/Screen.swift rename to src/logic/NSScreen.swift index cbca6c0b..f51b056e 100644 --- a/src/logic/Screen.swift +++ b/src/logic/NSScreen.swift @@ -1,9 +1,16 @@ import Cocoa -class Screen { - static func mainScreenRatio() -> CGFloat { - let mainScreen = NSScreen.main!.frame - return mainScreen.width / mainScreen.height +extension NSScreen { + func ratio() -> CGFloat { + return frame.width / frame.height + } + + func refreshRate() -> Double? { + if let screenNumber = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID, + let screenMode = CGDisplayCopyDisplayMode(screenNumber) { + return screenMode.refreshRate + } + return nil } static func preferred() -> NSScreen { @@ -18,16 +25,16 @@ class Screen { return NSScreen.screens.first { NSMouseInRect(NSEvent.mouseLocation, $0.frame, false) } } - static func repositionPanel(_ window: NSWindow, _ screen: NSScreen, _ alignment: VerticalAlignment) { - let screenFrame = screen.visibleFrame + func repositionPanel(_ window: NSWindow, _ alignment: VerticalAlignment) { + let screenFrame = visibleFrame let panelFrame = window.frame let x = screenFrame.minX + max(screenFrame.width - panelFrame.width, 0) * 0.5 let y = screenFrame.minY + max(screenFrame.height - panelFrame.height, 0) * alignment.rawValue window.setFrameOrigin(NSPoint(x: x, y: y)) } - static func uuid(_ screen: NSScreen) -> ScreenUuid? { - if let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")], + func uuid() -> ScreenUuid? { + if let screenNumber = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")], // these APIs implicitly unwrap their return values, but it can actually be nil thus we check let screenUuid = CGDisplayCreateUUIDFromDisplayID(screenNumber as! UInt32), let uuid = CFUUIDCreateString(nil, screenUuid.takeRetainedValue()) { diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index 68e2e99a..5fbc8ffe 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -240,7 +240,7 @@ class Preferences { static func rowCountDependingOnScreenRatio() -> String { // landscape; tested with 4/3, 16/10, 16/9 - if Screen.mainScreenRatio() > 1 { + if NSScreen.main!.ratio() > 1 { return "4" } // vertical; tested with 10/16 diff --git a/src/logic/Spaces.swift b/src/logic/Spaces.swift index e1dc36a2..6110bd44 100644 --- a/src/logic/Spaces.swift +++ b/src/logic/Spaces.swift @@ -19,7 +19,7 @@ class Spaces { static func refreshCurrentSpaceId() { // it seems that in some rare scenarios, some of these values are nil; we wrap to avoid crashing if let mainScreen = NSScreen.main, - let uuid = Screen.uuid(mainScreen) { + let uuid = mainScreen.uuid() { currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, uuid) } } diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index cf9f07bc..74e6576a 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -79,7 +79,13 @@ class Windows { } static func cycleFocusedWindowIndex(_ step: Int) { - updateFocusedWindowIndex(windowIndexAfterCycling(step)) + let nextIndex = windowIndexAfterCycling(step) + if ((step > 0 && nextIndex < focusedWindowIndex) || (step < 0 && nextIndex > focusedWindowIndex)) && + (KeyRepeatTimer.isARepeat || KeyRepeatTimer.timer?.isValid ?? false) { + KeyRepeatTimer.timer?.invalidate() + return + } + updateFocusedWindowIndex(nextIndex) } static func windowIndexAfterCycling(_ step: Int) -> Int { @@ -203,18 +209,17 @@ class Windows { return true } - static func checkIfShortcutsShouldBeDisabled() { - if let activeWindow = list.first { - App.app.shortcutsShouldBeDisabled = (!Preferences.disableShortcutsBlacklistOnlyFullscreen || activeWindow.isFullscreen) && - (Preferences.disableShortcutsBlacklist.first { blacklistedId in - if let id = activeWindow.application.runningApplication.bundleIdentifier { - return id.hasPrefix(blacklistedId) - } - return false - } != nil) - if App.app.shortcutsShouldBeDisabled && App.app.appIsBeingUsed { - App.app.hideUi() - } + static func checkIfShortcutsShouldBeDisabled(_ activeWindow: Window) { + let shortcutsShouldBeDisabled = (!Preferences.disableShortcutsBlacklistOnlyFullscreen || activeWindow.isFullscreen) && + (Preferences.disableShortcutsBlacklist.first { blacklistedId in + if let id = activeWindow.application.runningApplication.bundleIdentifier { + return id.hasPrefix(blacklistedId) + } + return false + } != nil) + KeyboardEvents.toggleGlobalShortcuts(shortcutsShouldBeDisabled) + if shortcutsShouldBeDisabled && App.app.appIsBeingUsed { + App.app.hideUi() } } } diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index 01fb90df..b203445b 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -5,29 +5,7 @@ func axObserverCallback(observer: AXObserver, element: AXUIElement, notification retryAxCallUntilTimeout { try handleEvent(type, element) } } -// if the window server is busy, it may not reply to AX calls. We retry right before the call times-out and returns a bogus value -func retryAxCallUntilTimeout(_ group: DispatchGroup? = nil, _ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) { - group?.enter() - BackgroundWork.axCallsQueue.async { - retryAxCallUntilTimeout_(group, fn, startTime) - } -} - -func retryAxCallUntilTimeout_(_ group: DispatchGroup?, _ fn: @escaping () throws -> Void, _ startTime: DispatchTime = DispatchTime.now()) { - do { - try fn() - group?.leave() - } catch { - let timePassedInSeconds = Double(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000 - if timePassedInSeconds < Double(AXUIElement.globalTimeoutInSeconds) { - BackgroundWork.axCallsQueue.asyncAfter(deadline: .now() + .milliseconds(10)) { - retryAxCallUntilTimeout_(group, fn, startTime) - } - } - } -} - -func handleEvent(_ type: String, _ element: AXUIElement) throws { +fileprivate func handleEvent(_ type: String, _ element: AXUIElement) throws { debugPrint("Accessibility event", type, type != kAXFocusedUIElementChangedNotification ? (try element.title() ?? "nil") : "nil") // events are handled concurrently, thus we check that the app is still running if let pid = try element.pid(), @@ -51,7 +29,7 @@ func handleEvent(_ type: String, _ element: AXUIElement) throws { } } -private func focusedUiElementChanged(_ element: AXUIElement, _ pid: pid_t) throws { +fileprivate func focusedUiElementChanged(_ element: AXUIElement, _ pid: pid_t) throws { let currentWindows = try AXUIElementCreateApplication(pid).windows() DispatchQueue.main.async { let windows = Windows.list.filter { w in @@ -67,21 +45,21 @@ private func focusedUiElementChanged(_ element: AXUIElement, _ pid: pid_t) throw } } -private func applicationActivated(_ element: AXUIElement) throws { +fileprivate func applicationActivated(_ element: AXUIElement) throws { if let appFocusedWindow = try element.focusedWindow(), let wid = try appFocusedWindow.cgWindowId() { DispatchQueue.main.async { // ensure alt-tab window remains key, so local shortcuts work if App.app.appIsBeingUsed { App.app.thumbnailsPanel.makeKeyAndOrderFront(nil) } - if let windows = Windows.updateLastFocus(appFocusedWindow, wid) { - App.app.refreshOpenUi(windows) + if let window = Windows.updateLastFocus(appFocusedWindow, wid) { + Windows.checkIfShortcutsShouldBeDisabled(window.first!) + App.app.refreshOpenUi(window) } - Windows.checkIfShortcutsShouldBeDisabled() } } } -private func applicationHiddenOrShown(_ element: AXUIElement, _ pid: pid_t, _ type: String) throws { +fileprivate func applicationHiddenOrShown(_ element: AXUIElement, _ pid: pid_t, _ type: String) throws { DispatchQueue.main.async { if let app = (Applications.list.first { $0.pid == pid }) { app.isHidden = type == kAXApplicationHiddenNotification @@ -94,7 +72,7 @@ private func applicationHiddenOrShown(_ element: AXUIElement, _ pid: pid_t, _ ty } } -private func windowCreated(_ element: AXUIElement, _ pid: pid_t) throws { +fileprivate func windowCreated(_ element: AXUIElement, _ pid: pid_t) throws { if let wid = try element.cgWindowId() { let axTitle = try element.title() let subrole = try element.subrole() @@ -117,11 +95,11 @@ private func windowCreated(_ element: AXUIElement, _ pid: pid_t) throws { } } -private func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) throws { +fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) throws { if let wid = try element.cgWindowId(), let runningApp = NSRunningApplication(processIdentifier: pid), // photoshop will focus a window *after* you focus another app - // we check that a focused window happens within an active app + // we check that a focused window happens within an active app runningApp.isActive { let axTitle = try element.title() let subrole = try element.subrole() @@ -134,17 +112,16 @@ private func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) throws { if let windows = Windows.updateLastFocus(element, wid) { App.app.refreshOpenUi(windows) } else if element.isActualWindow(runningApp, wid, isOnNormalLevel, axTitle, subrole, role), - let app = (Applications.list.first { $0.pid == pid }) { + let app = (Applications.list.first { $0.pid == pid }) { let window = Window(element, app, wid, axTitle, isFullscreen, isMinimized, position) Windows.appendAndUpdateFocus(window) App.app.refreshOpenUi([window]) } - Windows.checkIfShortcutsShouldBeDisabled() } } } -private func windowDestroyed(_ element: AXUIElement) throws { +fileprivate func windowDestroyed(_ element: AXUIElement) throws { let wid = try element.cgWindowId() DispatchQueue.main.async { if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }) { @@ -160,18 +137,18 @@ private func windowDestroyed(_ element: AXUIElement) throws { } } -private func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ type: String) throws { +fileprivate func windowMiniaturizedOrDeminiaturized(_ element: AXUIElement, _ type: String) throws { if let wid = try element.cgWindowId() { DispatchQueue.main.async { if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }) { - window.isMinimized = type == kAXWindowMiniaturizedNotification + window.isMinimized = type == kAXWindowMiniaturizedNotification App.app.refreshOpenUi([window]) } } } } -private func windowTitleChanged(_ element: AXUIElement) throws { +fileprivate func windowTitleChanged(_ element: AXUIElement) throws { if let wid = try element.cgWindowId() { let newTitle = try element.title() DispatchQueue.main.async { @@ -184,22 +161,24 @@ private func windowTitleChanged(_ element: AXUIElement) throws { } } -private func windowResized(_ element: AXUIElement) throws { +fileprivate func windowResized(_ element: AXUIElement) throws { // TODO: only trigger this at the end of the resize, not on every tick // currenly resizing a window will lag AltTab as it triggers too much UI work if let wid = try element.cgWindowId() { let isFullscreen = try element.isFullscreen() DispatchQueue.main.async { if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }) { - window.isFullscreen = isFullscreen + if window.isFullscreen != isFullscreen { + window.isFullscreen = isFullscreen + Windows.checkIfShortcutsShouldBeDisabled(window) + } App.app.refreshOpenUi([window]) } - Windows.checkIfShortcutsShouldBeDisabled() } } } -private func windowMoved(_ element: AXUIElement) throws { +fileprivate func windowMoved(_ element: AXUIElement) throws { if let wid = try element.cgWindowId() { let position = try element.position() DispatchQueue.main.async { diff --git a/src/logic/events/KeyboardEvents.swift b/src/logic/events/KeyboardEvents.swift index 3d073282..5a974945 100644 --- a/src/logic/events/KeyboardEvents.swift +++ b/src/logic/events/KeyboardEvents.swift @@ -1,5 +1,4 @@ import Cocoa -import Carbon.HIToolbox.Events import ShortcutRecorder fileprivate var eventTap: CFMachPort? @@ -8,50 +7,59 @@ class KeyboardEvents { static let signature = "altt".utf16.reduce(0) { ($0 << 8) + OSType($1) } // GetEventMonitorTarget/GetApplicationEventTarget also work, but require Accessibility Permission static let shortcutEventTarget = GetEventDispatcherTarget() - static let globalShortcuts = [ + static let globalShortcutsIds = [ "nextWindowShortcut": 0, "nextWindowShortcut2": 1, "holdShortcut": 2, "holdShortcut2": 3, ] - static var holdShortcutWasDown = ["holdShortcut": false, "holdShortcut2": false] - static var eventHotKeyRefs = [EventHotKeyRef?](repeating: nil, count: globalShortcuts.count) + static var eventHotKeyRefs = [String: EventHotKeyRef?]() static var hotModifierEventHandler: EventHandlerRef? - static var hotKeyEventHandler: EventHandlerRef? - static var localMonitor: Any? + static var hotKeyPressedEventHandler: EventHandlerRef? + static var hotKeyReleasedEventHandler: EventHandlerRef? + static var localMonitor: Any! - static func observe() { - localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { event in - // global .flagsChanged events are not send to the app when it's active, thus modifiers-only shortcuts should be matched in global and local handlers - if handleHotModifier(cocoaToCarbonFlags(event.modifierFlags)) || handleLocalEvents(event) { - return nil // don't propagate event - } - return event // propagate event - } - } - - static func addGlobalShortcut(_ shortcut: Shortcut, _ id: Int) { - addHandlerIfNeeded(shortcut) - registerHotKeyIfNeeded(id, shortcut) + static func addGlobalShortcut(_ controlId: String, _ shortcut: Shortcut) { + addGlobalHandlerIfNeeded(shortcut) + registerHotKeyIfNeeded(controlId, shortcut) toggleNativeCommandTabIfNeeded(shortcut, false) } - static func removeGlobalShortcut(_ id: Int, _ shortcut: Shortcut) { - UnregisterEventHotKey(eventHotKeyRefs[id]) - eventHotKeyRefs[id] = nil + static func removeGlobalShortcut(_ controlId: String, _ shortcut: Shortcut) { + unregisterHotKeyIfNeeded(controlId, shortcut) removeHandlerIfNeeded() toggleNativeCommandTabIfNeeded(shortcut, true) } - private static func registerHotKeyIfNeeded(_ id: Int, _ shortcut: Shortcut) { + private static func unregisterHotKeyIfNeeded(_ controlId: String, _ shortcut: Shortcut) { if shortcut.keyCode != .none { + UnregisterEventHotKey(eventHotKeyRefs[controlId]!) + eventHotKeyRefs[controlId] = nil + } + } + + static func registerHotKeyIfNeeded(_ controlId: String, _ shortcut: Shortcut) { + if shortcut.keyCode != .none { + let id = globalShortcutsIds[controlId]! let hotkeyId = EventHotKeyID(signature: signature, id: UInt32(id)) let key = UInt32(shortcut.carbonKeyCode) let mods = UInt32(shortcut.carbonModifierFlags) let options = UInt32(kEventHotKeyNoOptions) var shortcutsReference: EventHotKeyRef? RegisterEventHotKey(key, mods, hotkeyId, shortcutEventTarget, options, &shortcutsReference) - eventHotKeyRefs[id] = shortcutsReference + eventHotKeyRefs[controlId] = shortcutsReference + } + } + + static func toggleGlobalShortcuts(_ shouldDisable: Bool) { + if shouldDisable != App.app.globalShortcutsAreDisabled { + let fn = shouldDisable ? unregisterHotKeyIfNeeded : registerHotKeyIfNeeded + for shortcutId in globalShortcutsIds.keys { + let shortcut = ControlsTab.shortcuts[shortcutId]!.shortcut + fn(shortcutId, shortcut) + } + debugPrint("toggleGlobalShortcuts", shouldDisable) + App.app.globalShortcutsAreDisabled = shouldDisable } } @@ -61,120 +69,65 @@ class KeyboardEvents { } } - private static func addHandlerIfNeeded(_ shortcut: Shortcut) { + static func addLocalEventHandler() { + localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { event in + return handleEvent(nil, nil, event.type == .keyDown ? UInt32(event.keyCode) : nil, cocoaToCarbonFlags(event.modifierFlags), event.type == .keyDown ? event.isARepeat : false) ? nil : event + } + } + + private static func addGlobalHandlerIfNeeded(_ shortcut: Shortcut) { if shortcut.keyCode == .none && hotModifierEventHandler == nil { var eventTypes = [EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: OSType(kEventRawKeyModifiersChanged))] InstallEventHandler(GetEventMonitorTarget(), { (_: EventHandlerCallRef?, event: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in - if !App.app.shortcutsShouldBeDisabled { - var modifiers = UInt32(0) - GetEventParameter(event, EventParamName(kEventParamKeyModifiers), EventParamType(typeUInt32), nil, MemoryLayout.size, nil, &modifiers) - _ = handleHotModifier(modifiers) - } + var modifiers = UInt32(0) + GetEventParameter(event, EventParamName(kEventParamKeyModifiers), EventParamType(typeUInt32), nil, MemoryLayout.size, nil, &modifiers) + handleEvent(nil, nil, nil, modifiers, false) return noErr }, eventTypes.count, &eventTypes, nil, &hotModifierEventHandler) } - if shortcut.keyCode != .none && hotKeyEventHandler == nil { + if shortcut.keyCode != .none && hotKeyPressedEventHandler == nil { var eventTypes = [EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: OSType(kEventHotKeyPressed))] InstallEventHandler(shortcutEventTarget, { (_: EventHandlerCallRef?, event: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in - if !App.app.shortcutsShouldBeDisabled { - var id = EventHotKeyID() - GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &id) - handleHotKey(id) - } + var id = EventHotKeyID() + GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &id) + handleEvent(id, .down, nil, nil, false) return noErr - }, eventTypes.count, &eventTypes, nil, &hotKeyEventHandler) + }, eventTypes.count, &eventTypes, nil, &hotKeyPressedEventHandler) + } + if shortcut.keyCode != .none && hotKeyReleasedEventHandler == nil { + var eventTypes = [EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: OSType(kEventHotKeyReleased))] + InstallEventHandler(shortcutEventTarget, { (_: EventHandlerCallRef?, event: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in + var id = EventHotKeyID() + GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &id) + handleEvent(id, .up, nil, nil, false) + return noErr + }, eventTypes.count, &eventTypes, nil, &hotKeyReleasedEventHandler) } } private static func removeHandlerIfNeeded() { - if let hotKeyEventHandler_ = hotKeyEventHandler, (ControlsTab.globalShortcuts.values.allSatisfy { $0.keyCode == .none }) { - RemoveEventHandler(hotKeyEventHandler_) - hotKeyEventHandler = nil - } else if let hotModifierEventHandler_ = hotModifierEventHandler, (ControlsTab.globalShortcuts.values.allSatisfy { $0.keyCode != .none }) { + let globalShortcuts = ControlsTab.shortcuts.values.filter { $0.scope == .global } + if let hotKeyPressedEventHandler_ = hotKeyPressedEventHandler, let hotKeyReleasedEventHandler_ = hotKeyReleasedEventHandler, + (globalShortcuts.allSatisfy { $0.shortcut.keyCode == .none }) { + RemoveEventHandler(hotKeyPressedEventHandler_) + hotKeyPressedEventHandler = nil + RemoveEventHandler(hotKeyReleasedEventHandler_) + hotKeyReleasedEventHandler = nil + } else if let hotModifierEventHandler_ = hotModifierEventHandler, (globalShortcuts.allSatisfy { $0.shortcut.keyCode != .none }) { RemoveEventHandler(hotModifierEventHandler_) hotModifierEventHandler = nil } } } -fileprivate func handleHotModifier(_ modifiers: UInt32) -> Bool { - for (key, value) in ControlsTab.globalShortcuts { - if value.keyCode != .none { continue } - let shortcutIndex = KeyboardEvents.globalShortcuts[key]! - // modifiers down - if modifiers == value.carbonModifierFlags || modifiers == value.carbonModifierFlags | UInt32(alphaLock) { - if key.hasPrefix("holdShortcut") { - KeyboardEvents.holdShortcutWasDown[key] = true - } else { - if handleHotAny(key, shortcutIndex) { - return true - } - } - } - // modifiers up - else if key.hasPrefix("holdShortcut") && (modifiers & value.carbonModifierFlags == 0) && KeyboardEvents.holdShortcutWasDown[key]! { - KeyboardEvents.holdShortcutWasDown[key] = false - if ControlsTab.globalShortcuts["holdShortcut"]!.carbonModifierFlags == ControlsTab.globalShortcuts["holdShortcut"]!.carbonModifierFlags { - let otherKey = key == "holdShortcut" ? "holdShortcut2" : "holdShortcut" - KeyboardEvents.holdShortcutWasDown[otherKey] = false - } - if Preferences.shortcutStyle[App.app.shortcutIndex] == .focusOnRelease && handleHotAny(key, shortcutIndex) { - return true - } +@discardableResult +fileprivate func handleEvent(_ id: EventHotKeyID?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: UInt32?, _ isARepeat: Bool) -> Bool { + var someShortcutTriggered = false + for shortcut in ControlsTab.shortcuts.values { + if shortcut.matches(id, shortcutState, keyCode, modifiers, isARepeat) && shortcut.shouldTrigger() { + shortcut.executeAction(isARepeat) + someShortcutTriggered = true } } - return false -} - -fileprivate func handleHotKey(_ id: EventHotKeyID) { - let shortcutIndex = Int(id.id) - let key = Array(KeyboardEvents.globalShortcuts).first { $0.value == shortcutIndex }!.key - _ = handleHotAny(key, shortcutIndex) -} - -fileprivate func handleHotAny_(_ key: String, _ shortcutIndex: Int) -> Bool { - if key.hasPrefix("nextWindowShortcut") { - if (!App.app.appIsBeingUsed || App.app.shortcutIndex == (shortcutIndex % 2)) { - App.app.appIsBeingUsed = true - return true - } - } else { - if App.app.appIsBeingUsed && App.app.shortcutIndex == (shortcutIndex % 2) { - return true - } - } - return false -} - -fileprivate func localShortcutThatMatches(_ event: NSEvent) -> String? { - for (shortcutId, shortcut) in ControlsTab.localShortcuts { - if shortcutId.hasPrefix("holdShortcut") { - let postfix = App.app.shortcutIndex == 0 ? "" : "2" - if event.sr_keyEventType == .up && event.type == .flagsChanged && shortcut.keyCode == .none && event.modifierFlags.isDisjoint(with: shortcut.modifierFlags) && - shortcutId == "holdShortcut" + postfix && App.app.appIsBeingUsed && Preferences.shortcutStyle[App.app.shortcutIndex] == .focusOnRelease { - return shortcutId - } - } else if event.sr_keyEventType == .down && (shortcut.keyCode == .none || event.keyCode == shortcut.carbonKeyCode) && - event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(shortcut.modifierFlags) && - App.app.appIsBeingUsed { - return shortcutId - } - } - return nil -} - -fileprivate func handleHotAny(_ shortcutId: String, _ shortcutIndex: Int) -> Bool { - return executeActionIfShould(handleHotAny_(shortcutId, shortcutIndex) ? shortcutId : nil) -} - -fileprivate func handleLocalEvents(_ event: NSEvent) -> Bool { - return executeActionIfShould(localShortcutThatMatches(event)) -} - -fileprivate func executeActionIfShould(_ shortcutId: String?) -> Bool { - if let shortcutId = shortcutId { - DispatchQueue.main.async { () -> () in ControlsTab.shortcutsActions[shortcutId]!() } - return true - } - return false + return someShortcutTriggered } diff --git a/src/ui/App.swift b/src/ui/App.swift index 84d4811d..9de4b549 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -2,6 +2,8 @@ import Cocoa import Darwin import LetsMove import ShortcutRecorder +import AppCenterCrashes + let cgsMainConnectionId = CGSMainConnectionID() @@ -20,7 +22,7 @@ class App: AppCenterApplication, NSApplicationDelegate { var feedbackWindow: FeedbackWindow! var isFirstSummon = true var appIsBeingUsed = false - var shortcutsShouldBeDisabled = false + var globalShortcutsAreDisabled = false var shortcutIndex = 0 var appCenterDelegate: AppCenterCrash? @@ -56,7 +58,7 @@ class App: AppCenterApplication, NSApplicationDelegate { Applications.initialDiscovery() self.preferencesWindow = PreferencesWindow() self.feedbackWindow = FeedbackWindow() - KeyboardEvents.observe() + KeyboardEvents.addLocalEventHandler() MouseEvents.observe() // TODO: undeterministic; events in the queue may still be processing; good enough for now DispatchQueue.main.async { () -> () in Windows.sortByLevel() } @@ -159,7 +161,7 @@ class App: AppCenterApplication, NSApplicationDelegate { func showSecondaryWindow(_ window: NSWindow?) { if let window = window { - Screen.repositionPanel(window, Screen.preferred(), .appleCentered) + NSScreen.preferred().repositionPanel(window, .appleCentered) App.shared.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) } @@ -183,6 +185,11 @@ class App: AppCenterApplication, NSApplicationDelegate { } } + func previousWindowShortcutWithRepeatingKey() { + cycleSelection(.trailing) + KeyRepeatTimer.toggleRepeatingKeyPreviousWindow() + } + func focusSelectedWindow(_ window: Window?) { hideUi() guard !CGWindow.isMissionControlActive() else { return } @@ -196,7 +203,7 @@ class App: AppCenterApplication, NSApplicationDelegate { func refreshOpenUi(_ windowsToUpdate: [Window]? = nil) { guard appIsBeingUsed else { return } - let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) + let currentScreen = NSScreen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) // workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked, // switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually Spaces.refreshCurrentSpaceId() @@ -208,7 +215,7 @@ class App: AppCenterApplication, NSApplicationDelegate { thumbnailsPanel.setContentSize(thumbnailsPanel.thumbnailsView.frame.size) thumbnailsPanel.display() guard appIsBeingUsed else { return } - Screen.repositionPanel(thumbnailsPanel, currentScreen, .appleCentered) + currentScreen.repositionPanel(thumbnailsPanel, .appleCentered) } private func refreshSpecificWindows(_ windowsToUpdate: [Window]?, _ currentScreen: NSScreen) -> ()? { @@ -240,7 +247,7 @@ class App: AppCenterApplication, NSApplicationDelegate { // (At that point we won't be able to see what the element was, of course.) Spaces.idsAndIndexes = Spaces.allIdsAndIndexes() Windows.updateSpaces() - let screen = Screen.preferred() + let screen = NSScreen.preferred() self.shortcutIndex = shortcutIndex Windows.refreshWhichWindowsToShowTheUser(screen) Windows.reorderList() @@ -252,10 +259,11 @@ class App: AppCenterApplication, NSApplicationDelegate { } } else { cycleSelection(.leading) + KeyRepeatTimer.toggleRepeatingKeyNextWindow() } } - func rebuildUi(_ screen: NSScreen = Screen.preferred()) { + func rebuildUi(_ screen: NSScreen = NSScreen.preferred()) { guard appIsBeingUsed else { return } Windows.refreshFirstFewThumbnailsSync() guard appIsBeingUsed else { return } @@ -268,5 +276,6 @@ class App: AppCenterApplication, NSApplicationDelegate { Windows.refreshThumbnailsAsync(screen) guard appIsBeingUsed else { return } Applications.refreshBadges() + KeyRepeatTimer.toggleRepeatingKeyNextWindow() } } diff --git a/src/ui/main-window/ThumbnailsView.swift b/src/ui/main-window/ThumbnailsView.swift index fe5b1d65..bd28b44c 100644 --- a/src/ui/main-window/ThumbnailsView.swift +++ b/src/ui/main-window/ThumbnailsView.swift @@ -16,28 +16,35 @@ class ThumbnailsView: NSVisualEffectView { (1...100).forEach { _ in ThumbnailsView.recycledViews.append(ThumbnailView()) } } - func nextRow(_ direction: Direction) -> [ThumbnailView] { + func nextRow(_ direction: Direction) -> [ThumbnailView]? { let step = direction == .down ? 1 : -1 - let indexAfterStep = (Windows.focusedWindow()!.row! + step) % rows.count - let targetRowIndex = indexAfterStep < 0 ? rows.count + indexAfterStep : indexAfterStep - return rows[targetRowIndex] + let currentRow = Windows.focusedWindow()!.row! + let nextRow = (currentRow + step) % rows.count + let nextRow_ = nextRow < 0 ? rows.count + nextRow : nextRow + if ((step > 0 && nextRow_ < currentRow) || (step < 0 && nextRow_ > currentRow)) && + (KeyRepeatTimer.isARepeat || KeyRepeatTimer.timer?.isValid ?? false) { + KeyRepeatTimer.timer?.invalidate() + return nil + } + return rows[nextRow_] } func navigateUpOrDown(_ direction: Direction) { let focusedViewFrame = ThumbnailsView.recycledViews[Windows.focusedWindowIndex].frame let originCenter = NSMidX(focusedViewFrame) - let targetRow = nextRow(direction) - let leftSide = originCenter < NSMidX(frame) - let leadingSide = App.shared.userInterfaceLayoutDirection == .leftToRight ? leftSide : !leftSide - let iterable = leadingSide ? targetRow : targetRow.reversed() - let targetView = iterable.first { - if App.shared.userInterfaceLayoutDirection == .leftToRight { - return leadingSide ? NSMaxX($0.frame) > originCenter : NSMinX($0.frame) < originCenter - } - return leadingSide ? NSMinX($0.frame) < originCenter : NSMaxX($0.frame) > originCenter - } ?? iterable.last! - let targetIndex = ThumbnailsView.recycledViews.firstIndex(of: targetView)! - Windows.updateFocusedWindowIndex(targetIndex) + if let targetRow = nextRow(direction) { + let leftSide = originCenter < NSMidX(frame) + let leadingSide = App.shared.userInterfaceLayoutDirection == .leftToRight ? leftSide : !leftSide + let iterable = leadingSide ? targetRow : targetRow.reversed() + let targetView = iterable.first { + if App.shared.userInterfaceLayoutDirection == .leftToRight { + return leadingSide ? NSMaxX($0.frame) > originCenter : NSMinX($0.frame) < originCenter + } + return leadingSide ? NSMinX($0.frame) < originCenter : NSMaxX($0.frame) > originCenter + } ?? iterable.last! + let targetIndex = ThumbnailsView.recycledViews.firstIndex(of: targetView)! + Windows.updateFocusedWindowIndex(targetIndex) + } } func updateItemsAndLayout(_ screen: NSScreen) { diff --git a/src/ui/preferences-window/tabs/ControlsTab.swift b/src/ui/preferences-window/tabs/ControlsTab.swift index 29d97e77..e649de48 100644 --- a/src/ui/preferences-window/tabs/ControlsTab.swift +++ b/src/ui/preferences-window/tabs/ControlsTab.swift @@ -3,15 +3,14 @@ import ShortcutRecorder class ControlsTab { static var nextWindowShortcut: [NSControl]! - static var localShortcuts = [String: Shortcut]() - static var globalShortcuts = [String: Shortcut]() + static var shortcuts = [String: ATShortcut]() static var shortcutsActions = [ "holdShortcut": { App.app.focusTarget() }, "holdShortcut2": { App.app.focusTarget() }, "focusWindowShortcut": { App.app.focusTarget() }, "nextWindowShortcut": { App.app.showUiOrCycleSelection(0) }, "nextWindowShortcut2": { App.app.showUiOrCycleSelection(1) }, - "previousWindowShortcut": { App.app.cycleSelection(.trailing) }, + "previousWindowShortcut": { App.app.previousWindowShortcutWithRepeatingKey() }, "→": { App.app.cycleSelection(.right) }, "←": { App.app.cycleSelection(.left) }, "↑": { App.app.cycleSelection(.up) }, @@ -103,40 +102,37 @@ class ControlsTab { return (nextWindowShortcut, tab) } - private static func addShortcut(_ type: KeyEventType, _ shortcut: Shortcut, _ controlId: String, _ globalId: Int?) { - removeShortcutIfExists(controlId, type, globalId) // remove the previous shortcut - if let globalId = globalId { - globalShortcuts[controlId] = shortcut - KeyboardEvents.addGlobalShortcut(shortcut, globalId) - } else { - localShortcuts[controlId] = shortcut + private static func addShortcut(_ triggerPhase: ShortcutTriggerPhase, _ scope: ShortcutScope, _ shortcut: Shortcut, _ controlId: String, _ index: Int?) { + let atShortcut = ATShortcut(shortcut, controlId, scope, triggerPhase, index) + removeShortcutIfExists(controlId) // remove the previous shortcut + shortcuts[controlId] = atShortcut + if scope == .global { + KeyboardEvents.addGlobalShortcut(controlId, atShortcut.shortcut) } } @objc static func shortcutChangedCallback(_ sender: NSControl) { let controlId = sender.identifier!.rawValue - let globalId = KeyboardEvents.globalShortcuts[controlId] if controlId.hasPrefix("holdShortcut") { let i = controlId == "holdShortcut" ? 0 : 1 - addShortcut(.up, Shortcut(keyEquivalent: Preferences.holdShortcut[i])!, controlId, globalId) - // hold shortcuts need to also be local for the case of space transition reopening the ui - addShortcut(.up, Shortcut(keyEquivalent: Preferences.holdShortcut[i])!, controlId, nil) + addShortcut(.up, .global, Shortcut(keyEquivalent: Preferences.holdShortcut[i])!, controlId, i) if let s = nextWindowShortcut?[i] { shortcutChangedCallback(s) } } else { - let newValue = shortcutStringValue(controlId, sender, globalId) + let newValue = shortcutStringValue(controlId, sender) if newValue.isEmpty { - removeShortcutIfExists(controlId, .down, globalId) + removeShortcutIfExists(controlId) } else { - addShortcut(.down, Shortcut(keyEquivalent: newValue)!, controlId, globalId) + let i = controlId.hasPrefix("nextWindowShortcut") ? (controlId == "nextWindowShortcut" ? 0 : 1) : nil + addShortcut(.down, controlId.hasPrefix("nextWindowShortcut") ? .global : .local, Shortcut(keyEquivalent: newValue)!, controlId, i) } } } - static func shortcutStringValue(_ controlId: String, _ sender: NSControl, _ globalId: Int?) -> String { + static func shortcutStringValue(_ controlId: String, _ sender: NSControl) -> String { let baseValue = (sender as! RecorderControl).stringValue - if globalId != nil { + if KeyboardEvents.globalShortcutsIds[controlId] != nil { let holdShortcut = controlId == "nextWindowShortcut" ? Preferences.holdShortcut[0] : Preferences.holdShortcut[1] // remove the holdShortcut character in case they also use it in the other shortcuts let cleanedShortcut = holdShortcut + holdShortcut.reduce(baseValue, { $0.replacingOccurrences(of: String($1), with: "") }) @@ -151,22 +147,18 @@ class ControlsTab { @objc static func arrowKeysEnabledCallback(_ sender: NSControl) { let keys = ["←", "→", "↑", "↓"] if (sender as! NSButton).state == .on { - keys.forEach { addShortcut(.down, Shortcut(keyEquivalent: $0)!, $0, nil) } + keys.forEach { addShortcut(.down, .local, Shortcut(keyEquivalent: $0)!, $0, nil) } } else { - keys.forEach { removeShortcutIfExists($0, .down, nil) } + keys.forEach { removeShortcutIfExists($0) } } } - private static func removeShortcutIfExists(_ controlId: String, _ type: KeyEventType, _ globalId: Int?) { - if let globalId = globalId { - if globalShortcuts[controlId] != nil { - KeyboardEvents.removeGlobalShortcut(globalId, globalShortcuts[controlId]!) - globalShortcuts.removeValue(forKey: controlId) - } - } else { - if localShortcuts[controlId] != nil { - localShortcuts.removeValue(forKey: controlId) + private static func removeShortcutIfExists(_ controlId: String) { + if let atShortcut = shortcuts[controlId] { + if atShortcut.scope == .global { + KeyboardEvents.removeGlobalShortcut(controlId, atShortcut.shortcut) } + shortcuts.removeValue(forKey: controlId) } } }