feat: make shortcuts repeat when held down (closes #556)
This commit is contained in:
1 parent
fca2d1282b
commit
6803b0232e
16 files changed
+378
-242
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()) {
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user