feat: make shortcuts repeat when held down (closes #556)

This commit is contained in:
Louis Pontoise authored and lwouis committed 2020-09-25 19:15:15 +09:00
1 parent fca2d1282b
commit 6803b0232e
16 files changed
+378 -242

No files matched your search

+12 -4
View File
@@ -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 = "<group>"; };
BF0C84E11784B3B0900D0963 /* app.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = app.icns; sourceTree = "<group>"; };
BF0C86370093A080DD5974D8 /* convert_svg_to_png.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = convert_svg_to_png.sh; sourceTree = "<group>"; };
BF0C864F182B6155A66D241D /* KeyRepeatTimer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyRepeatTimer.swift; sourceTree = "<group>"; };
BF0C87B913292EE53EDE4220 /* menubar-1.svg */ = {isa = PBXFileReference; lastKnownFileType = file.svg; path = "menubar-1.svg"; sourceTree = "<group>"; };
BF0C884157B87BF0E17EF7F8 /* WindowControlView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WindowControlView.swift; sourceTree = "<group>"; };
BF0C889529FF19620EDAC295 /* accessibility@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "accessibility@2x.png"; sourceTree = "<group>"; };
BF0C88BF67CF4D71002A60EB /* menubar-1@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-1@2x.png"; sourceTree = "<group>"; };
BF0C88D6043B83B535BEAAA2 /* no */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = no; path = Localizable.strings; sourceTree = "<group>"; };
BF0C899605B39AB4EE0E8EB8 /* icon_512x512.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = icon_512x512.png; sourceTree = "<group>"; };
BF0C8BA452332236D972C60E /* ATShortcut.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ATShortcut.swift; sourceTree = "<group>"; };
BF0C8D3333653ACAEF730DD8 /* menubar-2@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-2@2x.png"; sourceTree = "<group>"; };
BF0C8E25373671B528AD448D /* screen-recording@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "screen-recording@2x.png"; sourceTree = "<group>"; };
BF0C8E54658B4DAEA0528200 /* menubar-3@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-3@2x.png"; sourceTree = "<group>"; };
@@ -234,7 +238,7 @@
D04BA653BD073CB58E2CFC93 /* ThumbnailsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsPanel.swift; sourceTree = "<group>"; };
D04BA662DF59DF9A97FDA598 /* de */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = de; path = Localizable.strings; sourceTree = "<group>"; };
D04BA67D904743A293768CB1 /* frontpage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = frontpage.jpg; sourceTree = "<group>"; };
D04BA68C2561D9EE4FD851B8 /* Screen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = "<group>"; };
D04BA68C2561D9EE4FD851B8 /* NSScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSScreen.swift; sourceTree = "<group>"; };
D04BA6BBFD0E87B8458E91D3 /* feature-suggestion.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = "feature-suggestion.md"; sourceTree = "<group>"; };
D04BA6D57A1456C07318B8EA /* GridView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
D04BA6ED38651224DB117A5A /* upload_symbols_to_appcenter.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = upload_symbols_to_appcenter.sh; sourceTree = "<group>"; };
@@ -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 = "<group>";
@@ -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;
};
+4
View File
@@ -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
+22
View File
@@ -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)
+1 -1
View File
@@ -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()
+102
View File
@@ -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
}
+2
View File
@@ -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() {
+46
View File
@@ -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
}
}
@@ -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()) {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
}
}
+18 -13
View File
@@ -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()
}
}
}
+21 -42
View File
@@ -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 {
+72 -119
View File
@@ -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<UInt32>.size, nil, &modifiers)
_ = handleHotModifier(modifiers)
}
var modifiers = UInt32(0)
GetEventParameter(event, EventParamName(kEventParamKeyModifiers), EventParamType(typeUInt32), nil, MemoryLayout<UInt32>.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<EventHotKeyID>.size, nil, &id)
handleHotKey(id)
}
var id = EventHotKeyID()
GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout<EventHotKeyID>.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<EventHotKeyID>.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
}
+16 -7
View File
@@ -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()
}
}
+23 -16
View File
@@ -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) {
@@ -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)
}
}
}