refactor: complete rework of the internals
closes #93 closes #24 closes #117 BREAKING CHANGE: Instead of asking the OS about the state of the whole system on trigger (what we do today; hard to do fast), or asking the state of the whole system on a timer (what HyperSwitch does today; inaccurate) - instead of one of 2 approaches, v3 observes the Accessibility events such as "an app was launched", "a window was closed". This means we build a cache as we receive these events in the background, and when the user trigger the app, we can show accurate state of the windows instantly. Of course there is no free lunch, so this approach has its own issues. However from my work on it from the past week, I'm very optimistic! The thing I'm the most excited about actually is not the perf (because on my machine even v2 is instant; I have a recent macbook and no 4k displays), but the fact that we will finally have the thumbnails in order of recently-used to least-recently-used, instead of the order of their stack (z-index) on the desktop. It's a big difference! There are many more limitations that are no longer applying also with this approach. More context: https://github.com/lwouis/alt-tab-macos/issues/45#issuecomment-571898826
This commit is contained in:
1 parent
22354da5ea
commit
547311ea5d
22 files changed
+635
-370
No files matched your search
@@ -10,11 +10,13 @@
|
||||
4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4807A6C523A9CD190052A53E /* SkyLight.framework */; };
|
||||
D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */; };
|
||||
D04BA0496ACF1427B6E9D369 /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA78E3B4E73B40DB77174 /* CGWindow.swift */; };
|
||||
D04BA1BA0B3F2E0A47883569 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAF13DFAA6930676D0492 /* Application.swift */; };
|
||||
D04BA20D4A240843293B3B52 /* Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA56355579F78776E6D51 /* Cell.swift */; };
|
||||
D04BA278D9EFA568C8D18A4C /* TrackedWindows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */; };
|
||||
D04BA2CBF0EFA04CC80EC1BC /* TrackedWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE80772D25834E440975 /* TrackedWindow.swift */; };
|
||||
D04BA2378832FD7E5DE3BC23 /* Applications.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA66B5B4143D2238F50B9 /* Applications.swift */; };
|
||||
D04BA278D9EFA568C8D18A4C /* Windows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1BED44EAEB77FED8A4 /* Windows.swift */; };
|
||||
D04BA2CBF0EFA04CC80EC1BC /* Window.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE80772D25834E440975 /* Window.swift */; };
|
||||
D04BA308162F8043F8561D03 /* AXUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA40A4291E4F310527DBF /* AXUIElement.swift */; };
|
||||
D04BA3261C7DA5F48310E654 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA90C6C36DB1D65BC2B66 /* Application.swift */; };
|
||||
D04BA3261C7DA5F48310E654 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA90C6C36DB1D65BC2B66 /* App.swift */; };
|
||||
D04BA4D356055A39B97712DE /* PrivateApis.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAF0DFC1F44322973CE1E /* PrivateApis.swift */; };
|
||||
D04BA57A871B7269BEBAFF84 /* Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA35456DA0DDA74F9687E /* Keyboard.swift */; };
|
||||
D04BA57FB9EF1373D59A1AA7 /* CGWindowID.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAEA3EDC4F80FA23DBEC4 /* CGWindowID.swift */; };
|
||||
@@ -26,6 +28,7 @@
|
||||
D04BA9119E2329DB5A35B3C7 /* ThumbnailsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */; };
|
||||
D04BA960DDD1D32A3019C835 /* CollectionViewCenterFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA3202A2C22C347E849B3 /* CollectionViewCenterFlowLayout.swift */; };
|
||||
D04BA9CCE02D30C8164A552A /* SystemPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */; };
|
||||
D04BAAD43731608067734ED3 /* DispatchQueues.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA56E285C3FCDA52ED262 /* DispatchQueues.swift */; };
|
||||
D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD32E130E4A061DC8332 /* Labels.swift */; };
|
||||
D04BAE2E8E9B9898A4DF9B3B /* FontIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAED53465957807CBF8B2 /* FontIcon.swift */; };
|
||||
D04BAE369A14C3126A1606FE /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8F1AA48A323EE5638DC /* HelperExtensions.swift */; };
|
||||
@@ -58,14 +61,16 @@
|
||||
D04BA4F23325560BC0BCDDB7 /* 7 windows - 2 lines - tall window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - tall window.jpg"; sourceTree = "<group>"; };
|
||||
D04BA51D43775E57CE91154A /* 3 windows - 1 line - wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line - wide window.jpg"; sourceTree = "<group>"; };
|
||||
D04BA56355579F78776E6D51 /* Cell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cell.swift; sourceTree = "<group>"; };
|
||||
D04BA56E285C3FCDA52ED262 /* DispatchQueues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DispatchQueues.swift; sourceTree = "<group>"; };
|
||||
D04BA5ABFA5457A86536E2E4 /* 5 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 1 line.jpg"; sourceTree = "<group>"; };
|
||||
D04BA5EB5ED248C8C22CC672 /* Spaces.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spaces.swift; sourceTree = "<group>"; };
|
||||
D04BA66B5B4143D2238F50B9 /* Applications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Applications.swift; sourceTree = "<group>"; };
|
||||
D04BA78E3B4E73B40DB77174 /* CGWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindow.swift; sourceTree = "<group>"; };
|
||||
D04BA7B6AAB0812631BBC7A2 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.info; path = Info.plist; sourceTree = "<group>"; };
|
||||
D04BA7ECCE728582D9ECA613 /* determine_version.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = determine_version.sh; sourceTree = "<group>"; };
|
||||
D04BA82F792DF53958D92572 /* alt-tab-macos.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "alt-tab-macos.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D04BA8F1AA48A323EE5638DC /* HelperExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HelperExtensions.swift; sourceTree = "<group>"; };
|
||||
D04BA90C6C36DB1D65BC2B66 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
||||
D04BA90C6C36DB1D65BC2B66 /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
|
||||
D04BA92541D46EA4F6943A72 /* package-lock.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "package-lock.json"; sourceTree = "<group>"; };
|
||||
D04BA9EF65B2E7AF9E3ADCA3 /* 2 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2 windows - 1 line.jpg"; sourceTree = "<group>"; };
|
||||
D04BAA34E0CB00DED7C04B4F /* 2-rows.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2-rows.jpg"; sourceTree = "<group>"; };
|
||||
@@ -75,7 +80,7 @@
|
||||
D04BAC02D60EF22D9CC7D969 /* commitlint.config.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = commitlint.config.js; sourceTree = "<group>"; };
|
||||
D04BAC159731F80FDAF4EA6C /* 1-row.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "1-row.jpg"; sourceTree = "<group>"; };
|
||||
D04BAC6AFC7F06D1A567F27A /* set_version_in_app.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = set_version_in_app.sh; sourceTree = "<group>"; };
|
||||
D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackedWindows.swift; sourceTree = "<group>"; };
|
||||
D04BAD1BED44EAEB77FED8A4 /* Windows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = "<group>"; };
|
||||
D04BAD1C9F215BCCD3B620AC /* alt_tab_macos.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = alt_tab_macos.entitlements; sourceTree = "<group>"; };
|
||||
D04BAD32E130E4A061DC8332 /* Labels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Labels.swift; sourceTree = "<group>"; };
|
||||
D04BAD40CE2D3A8AAC3819D0 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = file.gitignore; path = .gitignore; sourceTree = "<group>"; };
|
||||
@@ -85,11 +90,12 @@
|
||||
D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
|
||||
D04BAE1243C9B4BE3ED1B524 /* 7 windows - 2 lines - extra wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - extra wide window.jpg"; sourceTree = "<group>"; };
|
||||
D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsPanel.swift; sourceTree = "<group>"; };
|
||||
D04BAE80772D25834E440975 /* TrackedWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackedWindow.swift; sourceTree = "<group>"; };
|
||||
D04BAE80772D25834E440975 /* Window.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Window.swift; sourceTree = "<group>"; };
|
||||
D04BAEA3EDC4F80FA23DBEC4 /* CGWindowID.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindowID.swift; sourceTree = "<group>"; };
|
||||
D04BAED53465957807CBF8B2 /* FontIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FontIcon.swift; sourceTree = "<group>"; };
|
||||
D04BAF076A30A1BAFEDBEA66 /* 5 windows - 2 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 2 lines.jpg"; sourceTree = "<group>"; };
|
||||
D04BAF0DFC1F44322973CE1E /* PrivateApis.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrivateApis.swift; sourceTree = "<group>"; };
|
||||
D04BAF13DFAA6930676D0492 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
|
||||
D04BAF249324297C07E31164 /* frontpage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = frontpage.jpg; sourceTree = "<group>"; };
|
||||
D04BAFA277EAE3BDDDB61110 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; };
|
||||
F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HyperlinkLabel.swift; sourceTree = "<group>"; };
|
||||
@@ -219,12 +225,15 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D04BA35456DA0DDA74F9687E /* Keyboard.swift */,
|
||||
D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */,
|
||||
D04BAD1BED44EAEB77FED8A4 /* Windows.swift */,
|
||||
D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */,
|
||||
D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */,
|
||||
D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */,
|
||||
D04BA5EB5ED248C8C22CC672 /* Spaces.swift */,
|
||||
D04BAE80772D25834E440975 /* TrackedWindow.swift */,
|
||||
D04BAE80772D25834E440975 /* Window.swift */,
|
||||
D04BAF13DFAA6930676D0492 /* Application.swift */,
|
||||
D04BA66B5B4143D2238F50B9 /* Applications.swift */,
|
||||
D04BA56E285C3FCDA52ED262 /* DispatchQueues.swift */,
|
||||
);
|
||||
path = logic;
|
||||
sourceTree = "<group>";
|
||||
@@ -234,7 +243,7 @@
|
||||
children = (
|
||||
D04BA3202A2C22C347E849B3 /* CollectionViewCenterFlowLayout.swift */,
|
||||
D04BA56355579F78776E6D51 /* Cell.swift */,
|
||||
D04BA90C6C36DB1D65BC2B66 /* Application.swift */,
|
||||
D04BA90C6C36DB1D65BC2B66 /* App.swift */,
|
||||
D04BA02F476DE30C4647886C /* PreferencesPanel.swift */,
|
||||
D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */,
|
||||
D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */,
|
||||
@@ -351,8 +360,8 @@
|
||||
D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */,
|
||||
D04BA20D4A240843293B3B52 /* Cell.swift in Sources */,
|
||||
D04BA57A871B7269BEBAFF84 /* Keyboard.swift in Sources */,
|
||||
D04BA278D9EFA568C8D18A4C /* TrackedWindows.swift in Sources */,
|
||||
D04BA3261C7DA5F48310E654 /* Application.swift in Sources */,
|
||||
D04BA278D9EFA568C8D18A4C /* Windows.swift in Sources */,
|
||||
D04BA3261C7DA5F48310E654 /* App.swift in Sources */,
|
||||
D04BA70FF7262BF5F9E6E13B /* Preferences.swift in Sources */,
|
||||
D04BA6368E681BE3A408AC99 /* PreferencesPanel.swift in Sources */,
|
||||
D04BA9119E2329DB5A35B3C7 /* ThumbnailsPanel.swift in Sources */,
|
||||
@@ -368,8 +377,11 @@
|
||||
D04BAE2E8E9B9898A4DF9B3B /* FontIcon.swift in Sources */,
|
||||
D04BA4D356055A39B97712DE /* PrivateApis.swift in Sources */,
|
||||
D04BA6B6B703DCEFE892D5A4 /* Spaces.swift in Sources */,
|
||||
D04BA2CBF0EFA04CC80EC1BC /* TrackedWindow.swift in Sources */,
|
||||
D04BA2CBF0EFA04CC80EC1BC /* Window.swift in Sources */,
|
||||
D04BA57FB9EF1373D59A1AA7 /* CGWindowID.swift in Sources */,
|
||||
D04BA1BA0B3F2E0A47883569 /* Application.swift in Sources */,
|
||||
D04BA2378832FD7E5DE3BC23 /* Applications.swift in Sources */,
|
||||
D04BAAD43731608067734ED3 /* DispatchQueues.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,63 @@
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
// This list of keys is not exhaustive; it contains only the values used by this app
|
||||
// full public list: ApplicationServices.HIServices.AXAttributeConstants.swift
|
||||
// Note that the String value is transformed by the getters (e.g. kAXWindowsAttribute -> AXWindows)
|
||||
enum AXAttributeKey: String {
|
||||
case windows = "AXWindows"
|
||||
case minimized = "AXMinimized"
|
||||
case focusedWindow = "AXFocusedWindow"
|
||||
case subrole = "AXSubrole"
|
||||
}
|
||||
|
||||
extension AXUIElement {
|
||||
func value<T>(_ key: AXAttributeKey, _ target: T, _ type: AXValueType) -> T? {
|
||||
func cgWindowId() -> CGWindowID {
|
||||
var id = CGWindowID(0)
|
||||
_AXUIElementGetWindow(self, &id)
|
||||
return id
|
||||
}
|
||||
|
||||
func pid() -> pid_t {
|
||||
var pid = pid_t(0)
|
||||
AXUIElementGetPid(self, &pid)
|
||||
return pid
|
||||
}
|
||||
|
||||
func isActualWindow(_ isAppHidden: Bool = false) -> Bool {
|
||||
// TODO: should we displays windows that disappear when invoking Expose? (e.g. Outlook meeting reminder window) (see https://stackoverflow.com/a/49723037/2249756)
|
||||
// TODO: TotalFinder and XtraFinder double-window hacks (see #84)
|
||||
// TODO: should we display menubar windows? (e.g. iStats Pro dropdown menu)
|
||||
// Some non-windows have subrole: nil (e.g. some OS elements), "AXUnknown" (e.g. Bartender), "AXSystemDialog" (e.g. Intellij tooltips)
|
||||
// Some non-windows have title: nil (e.g. some OS elements)
|
||||
// Minimized windows or windows of a hidden app have subrole "AXDialog"
|
||||
return title() != nil && (subrole() == "AXStandardWindow" || isMinimized() || isAppHidden)
|
||||
}
|
||||
|
||||
func title() -> String? {
|
||||
return attribute(kAXTitleAttribute, String.self)
|
||||
}
|
||||
|
||||
func windows() -> [AXUIElement]? {
|
||||
return attribute(kAXWindowsAttribute, [AXUIElement].self)
|
||||
}
|
||||
|
||||
func isMinimized() -> Bool {
|
||||
return attribute(kAXMinimizedAttribute, Bool.self) == true
|
||||
}
|
||||
|
||||
func isHidden() -> Bool {
|
||||
return attribute(kAXHiddenAttribute, Bool.self) == true
|
||||
}
|
||||
|
||||
func focusedWindow() -> AXUIElement? {
|
||||
return attribute(kAXFocusedWindowAttribute, AXUIElement.self)
|
||||
}
|
||||
|
||||
func subrole() -> String? {
|
||||
return attribute(kAXSubroleAttribute, String.self)
|
||||
}
|
||||
|
||||
private func attribute<T>(_ key: String, _ type: T.Type) -> T? {
|
||||
var value: AnyObject?
|
||||
let result = AXUIElementCopyAttributeValue(self, key as CFString, &value)
|
||||
if result == .success, let value = value as? T {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func value<T>(_ key: String, _ target: T, _ type: AXValueType) -> T? {
|
||||
if let a = attribute(key, AXValue.self) {
|
||||
var value = target
|
||||
AXValueGetValue(a, type, &value)
|
||||
@@ -20,81 +65,4 @@ extension AXUIElement {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func attribute<T>(_ key: AXAttributeKey, _ type: T.Type) -> T? {
|
||||
var value: AnyObject?
|
||||
let result = AXUIElementCopyAttributeValue(self, key.rawValue as CFString, &value)
|
||||
if result == .success, let value = value as? T {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cgId() -> CGWindowID {
|
||||
var id = CGWindowID(0)
|
||||
_AXUIElementGetWindow(self, &id)
|
||||
return id
|
||||
}
|
||||
|
||||
func focusedWindow() -> AXUIElement? {
|
||||
return attribute(.focusedWindow, AXUIElement.self)
|
||||
}
|
||||
|
||||
func isActualWindow() -> Bool {
|
||||
let subrole = self.attribute(.subrole, String.self)
|
||||
return subrole != nil && subrole != "AXUnknown"
|
||||
}
|
||||
|
||||
func windows() -> [AXUIElement]? {
|
||||
return attribute(.windows, [AXUIElement].self)
|
||||
}
|
||||
|
||||
func window(_ id: CGWindowID) -> AXUIElement? {
|
||||
return windows()?.first(where: { return id == $0.cgId() })
|
||||
}
|
||||
|
||||
func isMinimized() -> Bool {
|
||||
return attribute(.minimized, Bool.self) == true
|
||||
}
|
||||
|
||||
func focus(_ id: CGWindowID) {
|
||||
// implementation notes: the following sequence of actions repeats some calls. This is necessary for
|
||||
// minimized windows on other spaces, and focuses windows faster (e.g. the Security & Privacy window)
|
||||
// macOS bug: when switching to a System Preferences window in another space, it switches to that space,
|
||||
// but quickly switches back to another window in that space
|
||||
// You can reproduce this buggy behaviour by clicking on the dock icon, proving it's an OS bug
|
||||
var elementConnection = UInt32(0)
|
||||
CGSGetWindowOwner(cgsMainConnectionId, id, &elementConnection)
|
||||
var psn = ProcessSerialNumber()
|
||||
CGSGetConnectionPSN(elementConnection, &psn)
|
||||
AXUIElementPerformAction(self, kAXRaiseAction as CFString)
|
||||
makeKeyWindow(psn, id)
|
||||
_SLPSSetFrontProcessWithOptions(&psn, id, .userGenerated)
|
||||
makeKeyWindow(psn, id)
|
||||
AXUIElementPerformAction(self, kAXRaiseAction as CFString)
|
||||
}
|
||||
|
||||
// The following function was ported from https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468
|
||||
func makeKeyWindow(_ psn: ProcessSerialNumber, _ wid: CGWindowID) -> Void {
|
||||
var wid_ = wid
|
||||
var psn_ = psn
|
||||
|
||||
var bytes1 = [UInt8](repeating: 0, count: 0xf8)
|
||||
bytes1[0x04] = 0xF8
|
||||
bytes1[0x08] = 0x01
|
||||
bytes1[0x3a] = 0x10
|
||||
|
||||
var bytes2 = [UInt8](repeating: 0, count: 0xf8)
|
||||
bytes2[0x04] = 0xF8
|
||||
bytes2[0x08] = 0x02
|
||||
bytes2[0x3a] = 0x10
|
||||
|
||||
memcpy(&bytes1[0x3c], &wid_, MemoryLayout<UInt32>.size)
|
||||
memset(&bytes1[0x20], 0xFF, 0x10)
|
||||
memcpy(&bytes2[0x3c], &wid_, MemoryLayout<UInt32>.size)
|
||||
memset(&bytes2[0x20], 0xFF, 0x10)
|
||||
|
||||
SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer<UInt8>(bytes1)).pointee))
|
||||
SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer<UInt8>(bytes2)).pointee))
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,49 @@
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
typealias CGWindow = [CGWindowKey.RawValue: Any]
|
||||
typealias CGWindow = [CFString: Any]
|
||||
|
||||
extension CGWindow {
|
||||
static func windows(_ option: CGWindowListOption) -> [CGWindow] {
|
||||
return CGWindowListCopyWindowInfo([.excludeDesktopElements, option], kCGNullWindowID) as! [CGWindow]
|
||||
}
|
||||
|
||||
func value<T>(_ key: CGWindowKey, _ type: T.Type) -> T? {
|
||||
return self[key.rawValue] as? T
|
||||
}
|
||||
|
||||
// workaround: filtering this criteria seems to remove non-windows UI elements
|
||||
func isNotMenubarOrOthers() -> Bool {
|
||||
return value(.layer, Int.self) == 0
|
||||
return layer() == 0
|
||||
}
|
||||
|
||||
// workaround: some apps like chrome use a window to implement the search popover
|
||||
func isReasonablyBig() -> Bool {
|
||||
let windowBounds = CGRect(dictionaryRepresentation: value(.bounds, CFDictionary.self)!)!
|
||||
let windowBounds = CGRect(dictionaryRepresentation: bounds()!)!
|
||||
return windowBounds.width > Preferences.minimumWindowSize && windowBounds.height > Preferences.minimumWindowSize
|
||||
}
|
||||
}
|
||||
|
||||
// This list of keys is not exhaustive; it contains only the values used by this app
|
||||
// full public list: CoreGraphics.CGWindow.swift
|
||||
enum CGWindowKey: String {
|
||||
case number = "kCGWindowNumber"
|
||||
case layer = "kCGWindowLayer"
|
||||
case bounds = "kCGWindowBounds"
|
||||
case ownerPID = "kCGWindowOwnerPID"
|
||||
case ownerName = "kCGWindowOwnerName"
|
||||
case name = "kCGWindowName"
|
||||
func id() -> CGWindowID? {
|
||||
return value(kCGWindowNumber, CGWindowID.self)
|
||||
}
|
||||
|
||||
func layer() -> Int? {
|
||||
return value(kCGWindowLayer, Int.self)
|
||||
}
|
||||
|
||||
func bounds() -> CFDictionary? {
|
||||
return value(kCGWindowBounds, CFDictionary.self)
|
||||
}
|
||||
|
||||
func ownerPID() -> pid_t? {
|
||||
return value(kCGWindowOwnerPID, pid_t.self)
|
||||
}
|
||||
|
||||
func ownerName() -> String? {
|
||||
return value(kCGWindowOwnerName, String.self)
|
||||
}
|
||||
|
||||
func title() -> String? {
|
||||
return value(kCGWindowName, String.self)
|
||||
}
|
||||
|
||||
private func value<T>(_ key: CFString, _ type: T.Type) -> T? {
|
||||
return self[key] as? T
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,8 @@ import Cocoa
|
||||
import Foundation
|
||||
|
||||
extension CGWindowID {
|
||||
func AXUIElementApplication(_ ownerPid: pid_t) -> AXUIElement {
|
||||
return AXUIElementCreateApplication(ownerPid)
|
||||
}
|
||||
|
||||
func AXUIElementOfOtherSpaceWindow(_ axApp: AXUIElement) -> AXUIElement? {
|
||||
CGSAddWindowsToSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId])
|
||||
let axWindow = axApp.window(self)
|
||||
CGSRemoveWindowsFromSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId])
|
||||
return axWindow
|
||||
func title() -> String? {
|
||||
return cgProperty("kCGSWindowTitle", String.self)
|
||||
}
|
||||
|
||||
func screenshot() -> CGImage? {
|
||||
@@ -32,6 +25,12 @@ extension CGWindowID {
|
||||
// CGSCaptureWindowsContentsToRectWithOptions(cgsMainConnectionId, &windowId_, true, .zero, [.windowCaptureNominalResolution, .captureIgnoreGlobalClipShape], &image)
|
||||
// return image
|
||||
}
|
||||
|
||||
private func cgProperty<T>(_ key: String, _ type: T.Type) -> T? {
|
||||
var value: AnyObject?
|
||||
CGSCopyWindowProperty(cgsMainConnectionId, self, key as CFString, &value)
|
||||
return value as? T
|
||||
}
|
||||
}
|
||||
|
||||
//class Testt {
|
||||
|
||||
@@ -107,6 +107,21 @@ func SLPSPostEventRecordTo(_ psn: inout ProcessSerialNumber, _ bytes: inout UInt
|
||||
@_silgen_name("_AXUIElementGetWindow") @discardableResult
|
||||
func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID) -> AXError
|
||||
|
||||
// returns the provided CGWindow property for the provided CGWindowID
|
||||
// * macOS 10.10+
|
||||
@_silgen_name("CGSCopyWindowProperty") @discardableResult
|
||||
func CGSCopyWindowProperty(_ cid: CGSConnectionID, _ wid: CGWindowID, _ property: CFString, _ value: inout CFTypeRef?) -> CGError
|
||||
|
||||
enum CGSSpaceMask: Int {
|
||||
case current = 5
|
||||
case other = 6
|
||||
case all = 7
|
||||
}
|
||||
|
||||
// get the CGSSpaceIDs for the given windows (CGWindowIDs)
|
||||
// * macOS 10.10+
|
||||
@_silgen_name("CGSCopySpacesForWindows")
|
||||
func CGSCopySpacesForWindows(_ cid: CGSConnectionID, _ mask: CGSSpaceMask.RawValue, _ wids: CFArray) -> CFArray
|
||||
|
||||
|
||||
|
||||
@@ -131,11 +146,6 @@ func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID)
|
||||
//@_silgen_name("GetProcessPID")
|
||||
//func GetProcessPID(_ psn: inout ProcessSerialNumber, _ pid: inout pid_t) -> Void
|
||||
//
|
||||
//// seems like it takes the normal CG keys, so might as well use the public API
|
||||
//// * macOS 10.10+
|
||||
//@_silgen_name("CGSCopyWindowProperty") @discardableResult
|
||||
//func CGSCopyWindowProperty(_ cid: CGSConnectionID, _ wid: CGWindowID, _ key: CFString, _ output: inout CFString) -> CGError
|
||||
//
|
||||
//// crashed the app with SIGSEGV
|
||||
//// * macOS 10.10+
|
||||
//@_silgen_name("CGSGetWindowType") @discardableResult
|
||||
@@ -154,6 +164,17 @@ func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID)
|
||||
//// * macOS 10.10+
|
||||
//@_silgen_name("CGSManagedDisplaySetCurrentSpace")
|
||||
//func CGSManagedDisplaySetCurrentSpace(_ cid: CGSConnectionID, _ display: CFString, _ sid: CGSSpaceID) -> Void
|
||||
//
|
||||
//// show provided spaces on top of the current space. It show windows from the provided spaces in the current space. Very weird behaviour and graphical glitch will happen when triggering Mission Control
|
||||
//// * macOS 10.10+
|
||||
//@_silgen_name("CGSShowSpaces")
|
||||
//func CGSShowSpaces(_ cid: CGSConnectionID, _ sids: NSArray) -> Void
|
||||
//
|
||||
//// hides provided spaces from the current space
|
||||
//// * macOS 10.10+
|
||||
//@_silgen_name("CGSHideSpaces")
|
||||
//func CGSHideSpaces(_ cid: CGSConnectionID, _ sids: NSArray) -> Void
|
||||
|
||||
//
|
||||
//// get space for window
|
||||
//// * macOS 10.10+
|
||||
@@ -186,18 +207,6 @@ func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID)
|
||||
//@_silgen_name("CGSGetWorkspaceWindowList") @discardableResult
|
||||
//func CGSGetWorkspaceWindowList(_ cid: CGSConnectionID, _ workspaceNumber: CGSSpaceID, _ count: Int, _ list: [Int], _ outCount: [Int]) -> OSStatus
|
||||
//
|
||||
//struct CGSSpaceMask: OptionSet {
|
||||
// let rawValue: UInt32
|
||||
// static let includesCurrent = CGSSpaceMask(rawValue: 1 << 0)
|
||||
// static let includesOthers = CGSSpaceMask(rawValue: 1 << 1)
|
||||
// static let includesUser = CGSSpaceMask(rawValue: 1 << 2)
|
||||
//}
|
||||
//
|
||||
//// get the CGSSpaceIDs for the given windows (CGWindowIDs). It's more efficient (i.e. fewer calls to the OS) to get windows of each space instead of space of each window
|
||||
//// * macOS 10.10+
|
||||
//@_silgen_name("CGSCopySpacesForWindows")
|
||||
//func CGSCopySpacesForWindows(_ connection: CGSConnectionID, _ mask: CGSSpaceMask, _ wids: CFArray) -> CFArray
|
||||
//
|
||||
//enum CGSSpaceType {
|
||||
// case user
|
||||
// case fullscreen
|
||||
@@ -225,11 +234,6 @@ func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID)
|
||||
//@_silgen_name("CGSGetSpaceManagementMode")
|
||||
//func CGSGetSpaceManagementMode(_ cid: CGSConnectionID) -> SpaceManagementMode
|
||||
//
|
||||
//// get spaces for the provided window_list
|
||||
//// * macOS 10.10+
|
||||
//@_silgen_name("CGSCopySpacesForWindows")
|
||||
//func CGSCopySpacesForWindows(_ cid: CGSConnectionID, _ selector: Int, _ window_list: CFArray) -> CFArray
|
||||
//
|
||||
//// The following function was ported from https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468
|
||||
//func windowManagerDeferWindowRaise(_ psn: ProcessSerialNumber, _ wid: CGWindowID) -> Void {
|
||||
// var wid_ = wid
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import Cocoa
|
||||
|
||||
class Application: NSObject {
|
||||
var runningApplication: NSRunningApplication
|
||||
var axUiElement: AXUIElement?
|
||||
var axObserver: AXObserver?
|
||||
|
||||
init(_ runningApplication: NSRunningApplication) {
|
||||
self.runningApplication = runningApplication
|
||||
super.init()
|
||||
if runningApplication.isFinishedLaunching {
|
||||
addAndObserveWindows()
|
||||
} else {
|
||||
runningApplication.addObserver(self, forKeyPath: "isFinishedLaunching", options: [.new], context: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func addAndObserveWindows() {
|
||||
axUiElement = AXUIElementCreateApplication(runningApplication.processIdentifier)
|
||||
AXObserverCreate(runningApplication.processIdentifier, axObserverApplicationCallback, &axObserver)
|
||||
observeAllWindows()
|
||||
}
|
||||
|
||||
private func observeAllWindows() {
|
||||
let windows = getActualWindows()
|
||||
debugPrint("Adding app: " + (runningApplication.bundleIdentifier ?? "nil"), windows.map { $0.title() })
|
||||
addWindows(windows)
|
||||
observeEvents(windows)
|
||||
}
|
||||
|
||||
func observeNewWindows() {
|
||||
var newWindows = [AXUIElement]()
|
||||
for window in getActualWindows() {
|
||||
guard Windows.listRecentlyUsedFirst.firstIndexThatMatches(window) == nil else { continue }
|
||||
newWindows.append(window)
|
||||
}
|
||||
addWindows(newWindows)
|
||||
}
|
||||
|
||||
private func getActualWindows() -> [AXUIElement] {
|
||||
return axUiElement!.windows()?.filter { $0.isActualWindow(runningApplication.isHidden) } ?? []
|
||||
}
|
||||
|
||||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
|
||||
guard let isFinishedLaunching = change![.newKey], isFinishedLaunching as! Bool else { return }
|
||||
runningApplication.removeObserver(self, forKeyPath: "isFinishedLaunching")
|
||||
addAndObserveWindows()
|
||||
}
|
||||
|
||||
private func addWindows(_ windows: [AXUIElement]) {
|
||||
Windows.listRecentlyUsedFirst.insert(contentsOf: windows.map { Window($0, self) }, at: 0)
|
||||
}
|
||||
|
||||
private func observeEvents(_ windows: [AXUIElement]) {
|
||||
guard let axObserver = axObserver else { return }
|
||||
let selfPointer = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
||||
AXObserverAddNotification(axObserver, axUiElement!, kAXApplicationActivatedNotification as CFString, selfPointer)
|
||||
AXObserverAddNotification(axObserver, axUiElement!, kAXFocusedWindowChangedNotification as CFString, selfPointer)
|
||||
AXObserverAddNotification(axObserver, axUiElement!, kAXWindowCreatedNotification as CFString, selfPointer)
|
||||
AXObserverAddNotification(axObserver, axUiElement!, kAXApplicationHiddenNotification as CFString, selfPointer)
|
||||
AXObserverAddNotification(axObserver, axUiElement!, kAXApplicationShownNotification as CFString, selfPointer)
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode)
|
||||
}
|
||||
}
|
||||
|
||||
func axObserverApplicationCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, applicationPointer: UnsafeMutableRawPointer?) -> Void {
|
||||
let application = Unmanaged<Application>.fromOpaque(applicationPointer!).takeUnretainedValue()
|
||||
let type = notificationName as String
|
||||
debugPrint("OS event: " + type, element.title())
|
||||
switch type {
|
||||
case kAXApplicationActivatedNotification:
|
||||
guard !(App.shared as! App).appIsBeingUsed,
|
||||
let appFocusedWindow = element.focusedWindow(),
|
||||
let existingIndex = Windows.listRecentlyUsedFirst.firstIndexThatMatches(appFocusedWindow) else { return }
|
||||
Windows.listRecentlyUsedFirst.insert(Windows.listRecentlyUsedFirst.remove(at: existingIndex), at: 0)
|
||||
case kAXApplicationHiddenNotification, kAXApplicationShownNotification:
|
||||
for window in Windows.listRecentlyUsedFirst {
|
||||
guard window.application.axUiElement!.pid() == element.pid() else { continue }
|
||||
window.isHidden = type == kAXApplicationHiddenNotification
|
||||
}
|
||||
(App.shared as! App).refreshOpenUi()
|
||||
case kAXWindowCreatedNotification:
|
||||
guard element.isActualWindow() else { return }
|
||||
// a window being un-minimized can trigger kAXWindowCreatedNotification
|
||||
guard Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) == nil else { return }
|
||||
let window = Window(element, application)
|
||||
Windows.listRecentlyUsedFirst.insert(window, at: 0)
|
||||
Windows.moveFocusedWindowIndexAfterWindowCreatedInBackground()
|
||||
// TODO: find a better way to get thumbnail of the new window
|
||||
window.refreshThumbnail()
|
||||
(App.shared as! App).refreshOpenUi()
|
||||
case kAXFocusedWindowChangedNotification:
|
||||
guard !(App.shared as! App).appIsBeingUsed,
|
||||
element.isActualWindow(),
|
||||
let existingIndex = Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) else { return }
|
||||
Windows.listRecentlyUsedFirst.insert(Windows.listRecentlyUsedFirst.remove(at: existingIndex), at: 0)
|
||||
default: return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import Cocoa
|
||||
|
||||
class Applications {
|
||||
static var map = [pid_t: Application]()
|
||||
static var appsObserver = RunningApplicationsObserver()
|
||||
|
||||
static func addInitialRunningApplications() {
|
||||
addRunningApplications(NSWorkspace.shared.runningApplications)
|
||||
}
|
||||
|
||||
static func addRunningApplications(_ runningApps: [NSRunningApplication]) {
|
||||
for app in filterApplications(runningApps) {
|
||||
Applications.map[app.processIdentifier] = Application(app)
|
||||
}
|
||||
}
|
||||
|
||||
static func observeRunningApplications() {
|
||||
NSWorkspace.shared.addObserver(Applications.appsObserver, forKeyPath: "runningApplications", options: [.old, .new], context: nil)
|
||||
}
|
||||
|
||||
static func reviewRunningApplicationsWindows() {
|
||||
for app in map.values {
|
||||
guard app.runningApplication.isFinishedLaunching else { continue }
|
||||
app.observeNewWindows()
|
||||
}
|
||||
}
|
||||
|
||||
static func removeApplications(_ runningApps: [NSRunningApplication]) {
|
||||
var someAppsAreAlreadyTerminated = false
|
||||
for runningApp in runningApps {
|
||||
guard runningApp.bundleIdentifier != nil else { someAppsAreAlreadyTerminated = true; continue }
|
||||
guard Applications.map[runningApp.processIdentifier] != nil else { continue }
|
||||
var windowsToKeep = [Window]()
|
||||
for window in Windows.listRecentlyUsedFirst {
|
||||
guard window.application.runningApplication.processIdentifier != runningApp.processIdentifier else { continue }
|
||||
windowsToKeep.append(window)
|
||||
}
|
||||
Windows.listRecentlyUsedFirst = windowsToKeep
|
||||
Applications.map.removeValue(forKey: runningApp.processIdentifier)
|
||||
guard Windows.listRecentlyUsedFirst.count > 0 else { (App.shared as! App).hideUi(); return }
|
||||
// TODO: implement of more sophisticated way to decide which thumbnail gets focused on app quit
|
||||
Windows.focusedWindowIndex = 1
|
||||
(App.shared as! App).refreshOpenUi()
|
||||
}
|
||||
// sometimes removed `runningApps` are already terminated by the time they reach this method so we can't match their pid in `Applications.map` above
|
||||
// we need to remove them based on their lack of `bundleIdentifier`
|
||||
if someAppsAreAlreadyTerminated {
|
||||
Windows.listRecentlyUsedFirst.removeAll(where: { $0.application.runningApplication.bundleIdentifier == nil })
|
||||
Applications.map = Applications.map.filter { $0.value.runningApplication.bundleIdentifier != nil }
|
||||
}
|
||||
}
|
||||
|
||||
private static func filterApplications(_ apps: [NSRunningApplication]) -> [NSRunningApplication] {
|
||||
// it would be nice to filter with $0.activationPolicy != .prohibited (see https://stackoverflow.com/a/26002033/2249756)
|
||||
// however some daemon processes can sometimes create windows, so we can't filter them out (e.g. CopyQ is .prohibited for some reason)
|
||||
return apps.filter { $0.bundleIdentifier != nil && $0.bundleIdentifier != NSRunningApplication.current.bundleIdentifier }
|
||||
}
|
||||
}
|
||||
|
||||
class RunningApplicationsObserver: NSObject {
|
||||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
|
||||
let type = NSKeyValueChange(rawValue: change![.kindKey]! as! UInt)
|
||||
switch type {
|
||||
case .insertion:
|
||||
let apps = change![.newKey] as! [NSRunningApplication]
|
||||
debugPrint("OS event: apps launched", apps.map { ($0.processIdentifier, $0.bundleIdentifier) })
|
||||
Applications.addRunningApplications(apps)
|
||||
case .removal:
|
||||
let apps = change![.oldKey] as! [NSRunningApplication]
|
||||
debugPrint("OS event: apps quit", apps.map { ($0.processIdentifier, $0.bundleIdentifier) })
|
||||
Applications.removeApplications(apps)
|
||||
default: return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
class DispatchQueues {
|
||||
static let focusActions = DispatchQueue(label: "focusActions", qos: .userInteractive)
|
||||
static let keyboardEvents = DispatchQueue(label: "keyboardEvents", qos: .userInteractive)
|
||||
}
|
||||
@@ -2,18 +2,15 @@ import Cocoa
|
||||
import Carbon.HIToolbox.Events
|
||||
|
||||
class Keyboard {
|
||||
static let backgroundQueue = DispatchQueue(label: "uiQueue", qos: .userInteractive, autoreleaseFrequency: .never)
|
||||
|
||||
static func listenToGlobalEvents(_ delegate: Application) {
|
||||
static func listenToGlobalEvents(_ delegate: App) {
|
||||
listenToGlobalKeyboardEvents(delegate)
|
||||
}
|
||||
}
|
||||
|
||||
var eventTap: CFMachPort?
|
||||
|
||||
func listenToGlobalKeyboardEvents(_ delegate: Application) {
|
||||
Keyboard.backgroundQueue.async {
|
||||
Thread.current.name = "uiQueue-thread"
|
||||
func listenToGlobalKeyboardEvents(_ app: App) {
|
||||
DispatchQueues.keyboardEvents.async {
|
||||
let eventMask = [CGEventType.keyDown, CGEventType.keyUp, CGEventType.flagsChanged].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue) })
|
||||
// CGEvent.tapCreate returns null if ensureAccessibilityCheckboxIsChecked() didn't pass
|
||||
eventTap = CGEvent.tapCreate(
|
||||
@@ -22,15 +19,15 @@ func listenToGlobalKeyboardEvents(_ delegate: Application) {
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: eventMask,
|
||||
callback: keyboardHandler,
|
||||
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(delegate).toOpaque()))
|
||||
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(app).toOpaque()))
|
||||
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
||||
CGEvent.tapEnable(tap: eventTap!, enable: true)
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
||||
CFRunLoopRun()
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchWork(_ application: Application, _ uiWorkShouldBeDone: Bool, _ fn: @escaping () -> Void) -> Unmanaged<CGEvent>? {
|
||||
func dispatchWork(_ application: App, _ uiWorkShouldBeDone: Bool, _ fn: @escaping () -> Void) -> Unmanaged<CGEvent>? {
|
||||
application.uiWorkShouldBeDone = uiWorkShouldBeDone
|
||||
DispatchQueue.main.async {
|
||||
fn()
|
||||
@@ -38,8 +35,8 @@ func dispatchWork(_ application: Application, _ uiWorkShouldBeDone: Bool, _ fn:
|
||||
return nil // previously focused app should not receive keys
|
||||
}
|
||||
|
||||
func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, delegate_: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {
|
||||
let application = Unmanaged<Application>.fromOpaque(delegate_!).takeUnretainedValue()
|
||||
func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, appPointer: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {
|
||||
let app = Unmanaged<App>.fromOpaque(appPointer!).takeUnretainedValue()
|
||||
if type == .keyDown || type == .keyUp || type == .flagsChanged {
|
||||
if let event = NSEvent(cgEvent: event_) {
|
||||
let isTab = event.keyCode == Preferences.tabKeyCode
|
||||
@@ -48,20 +45,20 @@ func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent,
|
||||
let isRightArrow = event.keyCode == kVK_RightArrow
|
||||
let isLeftArrow = event.keyCode == kVK_LeftArrow
|
||||
let isEscape = event.keyCode == kVK_Escape
|
||||
if type == .keyDown && isEscape && application.appIsBeingUsed {
|
||||
return dispatchWork(application, false, { application.hideUi() })
|
||||
if type == .keyDown && isEscape && app.appIsBeingUsed {
|
||||
return dispatchWork(app, false, { app.hideUi() })
|
||||
} else if isMetaDown && type == .keyDown {
|
||||
if isTab && event.modifierFlags.contains(.shift) {
|
||||
return dispatchWork(application, true, { application.showUiOrCycleSelection(-1) })
|
||||
return dispatchWork(app, true, { app.showUiOrCycleSelection(-1) })
|
||||
} else if isTab {
|
||||
return dispatchWork(application, true, { application.showUiOrCycleSelection(1) })
|
||||
} else if isRightArrow && application.appIsBeingUsed {
|
||||
return dispatchWork(application, true, { application.cycleSelection(1) })
|
||||
} else if isLeftArrow && application.appIsBeingUsed {
|
||||
return dispatchWork(application, true, { application.cycleSelection(-1) })
|
||||
return dispatchWork(app, true, { app.showUiOrCycleSelection(1) })
|
||||
} else if isRightArrow && app.appIsBeingUsed {
|
||||
return dispatchWork(app, true, { app.cycleSelection(1) })
|
||||
} else if isLeftArrow && app.appIsBeingUsed {
|
||||
return dispatchWork(app, true, { app.cycleSelection(-1) })
|
||||
}
|
||||
} else if isMetaChanged && !isMetaDown {
|
||||
return dispatchWork(application, false, { application.focusTarget() })
|
||||
return dispatchWork(app, false, { app.focusTarget() })
|
||||
}
|
||||
}
|
||||
} else if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout {
|
||||
|
||||
@@ -27,14 +27,18 @@ class Screen {
|
||||
return NSSize(width: frame.width * Preferences.maxScreenUsage!, height: frame.height * Preferences.maxScreenUsage!)
|
||||
}
|
||||
|
||||
static func showPanel(_ panel: NSPanel, _ screen: NSScreen, _ alignment: VerticalAlignment) {
|
||||
static func repositionPanel(_ panel: NSPanel, _ screen: NSScreen, _ alignment: VerticalAlignment) {
|
||||
let screenFrame = screen.visibleFrame
|
||||
let panelFrame = panel.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
|
||||
panel.setFrameOrigin(NSPoint(x: x, y: y))
|
||||
}
|
||||
|
||||
static func showPanel(_ panel: NSPanel) {
|
||||
let panelFrame = panel.frame
|
||||
panel.makeKeyAndOrderFront(nil)
|
||||
Application.shared.arrangeInFront(nil)
|
||||
App.shared.arrangeInFront(nil)
|
||||
}
|
||||
|
||||
static func mainUuid() -> CFString {
|
||||
|
||||
@@ -2,9 +2,31 @@ import Cocoa
|
||||
import Foundation
|
||||
|
||||
class Spaces {
|
||||
static var singleSpace = true
|
||||
static var currentSpaceId = CGSSpaceID(1)
|
||||
static var currentSpaceIndex = SpaceIndex(1)
|
||||
static var visitedSpaces = [CGSSpaceID: Bool]()
|
||||
static var isSingleSpace = true
|
||||
|
||||
static func observeSpaceChanges() {
|
||||
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: nil, using: { _ in
|
||||
updateCurrentSpace()
|
||||
guard visitedSpaces[Spaces.currentSpaceId] == nil else { return }
|
||||
visitedSpaces[Spaces.currentSpaceId] = true
|
||||
// when visiting a space for the first time, we review windows that we could not gather before the visit, from the other space
|
||||
Applications.reviewRunningApplicationsWindows()
|
||||
})
|
||||
}
|
||||
|
||||
static func updateCurrentSpace() {
|
||||
Spaces.currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, Screen.mainUuid())
|
||||
Spaces.currentSpaceIndex = allIdsAndIndexes().first { $0.0 == Spaces.currentSpaceId }!.1
|
||||
debugPrint("current space", Spaces.currentSpaceId)
|
||||
}
|
||||
|
||||
static func updateInitialSpace() {
|
||||
updateCurrentSpace()
|
||||
visitedSpaces[Spaces.currentSpaceId] = true
|
||||
}
|
||||
|
||||
static func allIdsAndIndexes() -> [(CGSSpaceID, SpaceIndex)] {
|
||||
return (CGSCopyManagedDisplaySpaces(cgsMainConnectionId) as! [NSDictionary])
|
||||
@@ -17,6 +39,10 @@ class Spaces {
|
||||
var clear_tags = UInt64(0)
|
||||
return CGSCopyWindowsWithOptionsAndTags(cgsMainConnectionId, 0, spaceIds as CFArray, 2, &set_tags, &clear_tags) as! [CGWindowID]
|
||||
}
|
||||
|
||||
static func updateIsSingleSpace() {
|
||||
isSingleSpace = allIdsAndIndexes().count == 1
|
||||
}
|
||||
}
|
||||
|
||||
typealias SpaceIndex = Int
|
||||
@@ -17,7 +17,7 @@ class SystemPermissions {
|
||||
// macOS 10.15+
|
||||
static func ensureScreenRecordingCheckboxIsChecked() {
|
||||
let firstWindow = CGWindow.windows(.optionOnScreenOnly)[0]
|
||||
if let cgId = firstWindow.value(.number, CGWindowID.self), cgId.screenshot() == nil {
|
||||
if let cgId = firstWindow.id(), cgId.screenshot() == nil {
|
||||
debugPrint("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Screen Recording.",
|
||||
"Please authorize and re-launch.",
|
||||
"See https://dropshare.zendesk.com/hc/en-us/articles/360033453434-Enabling-Screen-Recording-Permission-on-macOS-Catalina-10-15-",
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
class TrackedWindow {
|
||||
var cgWindow: CGWindow
|
||||
var id: CGWindowID
|
||||
var title: String
|
||||
var thumbnail: NSImage?
|
||||
var icon: NSImage?
|
||||
var app: NSRunningApplication
|
||||
var axApp: AXUIElement
|
||||
var axWindow: AXUIElement?
|
||||
var isHidden: Bool
|
||||
var isMinimized: Bool
|
||||
var spaceId: CGSSpaceID?
|
||||
var spaceIndex: SpaceIndex?
|
||||
var rank: WindowRank?
|
||||
|
||||
init(_ cgWindow: CGWindow, _ cgId: CGWindowID, _ app: NSRunningApplication, _ axApp: AXUIElement, _ isHidden: Bool, _ isMinimized: Bool, _ axWindow: AXUIElement?, _ spaceId: CGSSpaceID?, _ spaceIndex: SpaceIndex?, _ rank: WindowRank?) {
|
||||
self.cgWindow = cgWindow
|
||||
self.id = cgId
|
||||
let cgTitle = cgWindow.value(.name, String.self)
|
||||
let cgOwnerName = cgWindow.value(.ownerName, String.self)
|
||||
// for some reason Google Chrome uses a unicode 0-width no-break space character in their empty window title
|
||||
self.title = cgTitle != nil && cgTitle != "" && cgTitle != "" ? cgTitle! : cgOwnerName ?? ""
|
||||
self.app = app
|
||||
self.axApp = axApp
|
||||
self.icon = self.app.icon
|
||||
if let cgImage = cgId.screenshot() {
|
||||
self.thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
|
||||
}
|
||||
self.axWindow = axWindow
|
||||
self.isHidden = isHidden
|
||||
self.isMinimized = isMinimized
|
||||
self.spaceId = spaceId
|
||||
self.spaceIndex = spaceIndex
|
||||
self.rank = rank
|
||||
}
|
||||
|
||||
func focus() {
|
||||
let onCurrentSpace = axWindow != nil
|
||||
if !onCurrentSpace {
|
||||
axWindow = id.AXUIElementOfOtherSpaceWindow(axApp)
|
||||
}
|
||||
axWindow?.focus(id)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
class TrackedWindows {
|
||||
static var list = [TrackedWindow]()
|
||||
static var focusedWindowIndex = Int(0)
|
||||
|
||||
static func focusedWindow() -> TrackedWindow? {
|
||||
return list.count > focusedWindowIndex ? list[focusedWindowIndex] : nil
|
||||
}
|
||||
|
||||
static func moveFocusedWindowIndex(_ step: Int) -> Int {
|
||||
return focusedWindowIndex + step < 0 ? list.count - 1 : (focusedWindowIndex + step) % list.count
|
||||
}
|
||||
|
||||
static func refreshList(_ step: Int) {
|
||||
list.removeAll()
|
||||
focusedWindowIndex = 0
|
||||
let spaces = Spaces.allIdsAndIndexes()
|
||||
Spaces.currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, Screen.mainUuid())
|
||||
Spaces.currentSpaceIndex = spaces.first { $0.0 == Spaces.currentSpaceId }!.1
|
||||
filterAndAddToList(mapWindowsWithRankAndSpace(spaces))
|
||||
isSingleSpace()
|
||||
sortList()
|
||||
}
|
||||
|
||||
private class func isSingleSpace() {
|
||||
if list.count > 0 {
|
||||
let firstSpaceIndex = list[0].spaceIndex
|
||||
for window in list {
|
||||
if window.spaceIndex != nil && window.spaceIndex != firstSpaceIndex {
|
||||
Spaces.singleSpace = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
Spaces.singleSpace = true
|
||||
}
|
||||
|
||||
private static func mapWindowsWithRankAndSpace(_ spaces: [(CGSSpaceID, SpaceIndex)]) -> WindowsMap {
|
||||
var windowSpaceMap: [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank?)] = [:]
|
||||
for (spaceId, spaceIndex) in spaces {
|
||||
Spaces.windowsInSpaces([spaceId]).forEach {
|
||||
windowSpaceMap[$0] = (spaceId, spaceIndex, nil)
|
||||
}
|
||||
}
|
||||
Spaces.windowsInSpaces(spaces.map { $0.0 }).enumerated().forEach {
|
||||
windowSpaceMap[$0.element]!.2 = $0.offset
|
||||
}
|
||||
return windowSpaceMap as! WindowsMap
|
||||
}
|
||||
|
||||
private static func sortList() {
|
||||
list.sort(by: {
|
||||
if $0.rank == nil {
|
||||
return false
|
||||
}
|
||||
if $1.rank == nil {
|
||||
return true
|
||||
}
|
||||
return $0.rank! < $1.rank!
|
||||
})
|
||||
}
|
||||
|
||||
private static func filterAndAddToList(_ windowsMap: WindowsMap) {
|
||||
// order and short-circuit of checks in this method is important for performance
|
||||
for cgWindow in CGWindow.windows(.optionAll) {
|
||||
guard let cgId = cgWindow.value(.number, CGWindowID.self),
|
||||
let ownerPid = cgWindow.value(.ownerPID, pid_t.self),
|
||||
let app = NSRunningApplication(processIdentifier: ownerPid),
|
||||
cgWindow.isNotMenubarOrOthers(),
|
||||
cgWindow.isReasonablyBig() else {
|
||||
continue
|
||||
}
|
||||
let axApp = cgId.AXUIElementApplication(ownerPid)
|
||||
let (spaceId, spaceIndex, rank) = windowsMap[cgId] ?? (nil, nil, nil)
|
||||
if let (isMinimized, isHidden, axWindow) = filter(cgId, spaceId, app, axApp) {
|
||||
list.append(TrackedWindow(cgWindow, cgId, app, axApp, isHidden, isMinimized, axWindow, spaceId, spaceIndex, rank))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func filter(_ cgId: CGWindowID, _ spaceId: CGSSpaceID?, _ app: NSRunningApplication, _ axApp: AXUIElement) -> (Bool, Bool, AXUIElement?)? {
|
||||
// window is in another space
|
||||
if spaceId != nil && spaceId != Spaces.currentSpaceId {
|
||||
return (false, false, nil)
|
||||
}
|
||||
// window is in the current space, or is hidden/minimized
|
||||
if let axWindow = axApp.window(cgId), axWindow.isActualWindow() {
|
||||
if spaceId != nil {
|
||||
return (false, false, axWindow)
|
||||
}
|
||||
if app.isHidden {
|
||||
return (axWindow.isMinimized(), true, axWindow)
|
||||
}
|
||||
if axWindow.isMinimized() {
|
||||
return (true, false, axWindow)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
typealias WindowRank = Int
|
||||
typealias WindowsMap = [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)]
|
||||
@@ -0,0 +1,126 @@
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
class Window {
|
||||
var cgWindowId: CGWindowID
|
||||
var title: String
|
||||
var thumbnail: NSImage?
|
||||
var icon: NSImage?
|
||||
var isHidden: Bool
|
||||
var isMinimized: Bool
|
||||
var spaceId: CGSSpaceID?
|
||||
var spaceIndex: SpaceIndex?
|
||||
var axUiElement: AXUIElement
|
||||
var application: Application
|
||||
var axObserver: AXObserver?
|
||||
|
||||
init(_ axUiElement: AXUIElement, _ application: Application) {
|
||||
// TODO: make a efficient batched AXUIElementCopyMultipleAttributeValues call once for each window, and store the values
|
||||
self.axUiElement = axUiElement
|
||||
self.application = application
|
||||
self.cgWindowId = axUiElement.cgWindowId()
|
||||
self.icon = application.runningApplication.icon
|
||||
self.isHidden = application.runningApplication.isHidden
|
||||
self.isMinimized = axUiElement.isMinimized()
|
||||
self.spaceId = Spaces.currentSpaceId
|
||||
self.spaceIndex = Spaces.currentSpaceIndex
|
||||
self.title = Window.bestEffortTitle(axUiElement, cgWindowId, application)
|
||||
debugPrint("Adding window: " + title, application.runningApplication.bundleIdentifier, Spaces.currentSpaceId, Spaces.currentSpaceIndex)
|
||||
observeEvents()
|
||||
}
|
||||
|
||||
private func observeEvents() {
|
||||
AXObserverCreate(application.runningApplication.processIdentifier, axObserverWindowCallback, &axObserver)
|
||||
guard let axObserver = axObserver else { return }
|
||||
AXObserverAddNotification(axObserver, axUiElement, kAXUIElementDestroyedNotification as CFString, nil)
|
||||
AXObserverAddNotification(axObserver, axUiElement, kAXTitleChangedNotification as CFString, nil)
|
||||
AXObserverAddNotification(axObserver, axUiElement, kAXWindowMiniaturizedNotification as CFString, nil)
|
||||
AXObserverAddNotification(axObserver, axUiElement, kAXWindowDeminiaturizedNotification as CFString, nil)
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode)
|
||||
}
|
||||
|
||||
func refreshThumbnail() {
|
||||
guard let cgImage = cgWindowId.screenshot() else { return }
|
||||
thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
|
||||
}
|
||||
|
||||
func focus() {
|
||||
// implementation notes: the following sequence of actions repeats some calls. This is necessary for
|
||||
// minimized windows on other spaces, and focuses windows faster (e.g. the Security & Privacy window)
|
||||
// macOS bug: when switching to a System Preferences window in another space, it switches to that space,
|
||||
// but quickly switches back to another window in that space
|
||||
// You can reproduce this buggy behaviour by clicking on the dock icon, proving it's an OS bug
|
||||
DispatchQueues.focusActions.async {
|
||||
var elementConnection = UInt32(0)
|
||||
CGSGetWindowOwner(cgsMainConnectionId, self.cgWindowId, &elementConnection)
|
||||
var psn = ProcessSerialNumber()
|
||||
CGSGetConnectionPSN(elementConnection, &psn)
|
||||
AXUIElementPerformAction(self.axUiElement, kAXRaiseAction as CFString)
|
||||
self.makeKeyWindow(psn)
|
||||
_SLPSSetFrontProcessWithOptions(&psn, self.cgWindowId, .userGenerated)
|
||||
self.makeKeyWindow(psn)
|
||||
AXUIElementPerformAction(self.axUiElement, kAXRaiseAction as CFString)
|
||||
}
|
||||
}
|
||||
|
||||
// The following function was ported from https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468
|
||||
func makeKeyWindow(_ psn: ProcessSerialNumber) -> Void {
|
||||
var psn_ = psn
|
||||
|
||||
var bytes1 = [UInt8](repeating: 0, count: 0xf8)
|
||||
bytes1[0x04] = 0xF8
|
||||
bytes1[0x08] = 0x01
|
||||
bytes1[0x3a] = 0x10
|
||||
|
||||
var bytes2 = [UInt8](repeating: 0, count: 0xf8)
|
||||
bytes2[0x04] = 0xF8
|
||||
bytes2[0x08] = 0x02
|
||||
bytes2[0x3a] = 0x10
|
||||
|
||||
memcpy(&bytes1[0x3c], &cgWindowId, MemoryLayout<UInt32>.size)
|
||||
memset(&bytes1[0x20], 0xFF, 0x10)
|
||||
memcpy(&bytes2[0x3c], &cgWindowId, MemoryLayout<UInt32>.size)
|
||||
memset(&bytes2[0x20], 0xFF, 0x10)
|
||||
|
||||
SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer<UInt8>(bytes1)).pointee))
|
||||
SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer<UInt8>(bytes2)).pointee))
|
||||
}
|
||||
|
||||
// for some windows (e.g. Slack), the AX API doesn't return a title; we try CG API; finally we resort to the app name
|
||||
static func bestEffortTitle(_ axUiElement: AXUIElement, _ cgWindowId: CGWindowID, _ application: Application) -> String {
|
||||
if let axTitle = axUiElement.title(), !axTitle.isEmpty {
|
||||
return axTitle
|
||||
}
|
||||
if let cgTitle = cgWindowId.title(), !cgTitle.isEmpty {
|
||||
return cgTitle
|
||||
}
|
||||
return application.runningApplication.localizedName ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
func axObserverWindowCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, _: UnsafeMutableRawPointer?) -> Void {
|
||||
let type = notificationName as String
|
||||
debugPrint("OS event: " + type, element.title())
|
||||
switch type {
|
||||
case kAXUIElementDestroyedNotification:
|
||||
guard let existingIndex = Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) else { return }
|
||||
Windows.listRecentlyUsedFirst.remove(at: existingIndex)
|
||||
guard Windows.listRecentlyUsedFirst.count > 0 else { (App.shared as! App).hideUi(); return }
|
||||
Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex)
|
||||
(App.shared as! App).refreshOpenUi()
|
||||
case kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification:
|
||||
guard let window = Windows.listRecentlyUsedFirst.firstWindowThatMatches(element) else { return }
|
||||
window.isMinimized = type == kAXWindowMiniaturizedNotification
|
||||
// TODO: find a better way to get thumbnail of the new window (when AltTab is triggered min/demin animation)
|
||||
window.refreshThumbnail()
|
||||
(App.shared as! App).refreshOpenUi()
|
||||
case kAXTitleChangedNotification:
|
||||
guard element.isActualWindow(),
|
||||
let window = Windows.listRecentlyUsedFirst.firstWindowThatMatches(element),
|
||||
let newTitle = window.axUiElement.title(),
|
||||
newTitle != window.title else { return }
|
||||
window.title = newTitle
|
||||
(App.shared as! App).refreshOpenUi()
|
||||
default: return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
class Windows {
|
||||
static var listRecentlyUsedFirst = [Window]()
|
||||
static var focusedWindowIndex = Array<Window>.Index(0)
|
||||
|
||||
static func focusedWindow() -> Window? {
|
||||
return listRecentlyUsedFirst.count > focusedWindowIndex ? listRecentlyUsedFirst[focusedWindowIndex] : nil
|
||||
}
|
||||
|
||||
static func cycleFocusedWindowIndex(_ step: Array<Window>.Index) {
|
||||
focusedWindowIndex = focusedWindowIndex + step < 0 ? listRecentlyUsedFirst.count - 1 : (focusedWindowIndex + step) % listRecentlyUsedFirst.count
|
||||
}
|
||||
|
||||
static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ destroyedWindowIndex: Array<Window>.Index) {
|
||||
if focusedWindowIndex <= destroyedWindowIndex {
|
||||
focusedWindowIndex -= 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
static func moveFocusedWindowIndexAfterWindowCreatedInBackground() {
|
||||
focusedWindowIndex += 1
|
||||
}
|
||||
|
||||
static func updateSpaces() {
|
||||
let spacesMap = Spaces.allIdsAndIndexes()
|
||||
for window in listRecentlyUsedFirst {
|
||||
guard let spaceId = (CGSCopySpacesForWindows(cgsMainConnectionId, CGSSpaceMask.all.rawValue, [window.cgWindowId] as CFArray) as! [CGSSpaceID]).first else { continue }
|
||||
window.spaceId = spaceId
|
||||
window.spaceIndex = spacesMap.first { $0.0 == spaceId }!.1
|
||||
}
|
||||
}
|
||||
|
||||
static func sortByLevel() {
|
||||
var windowLevelMap = [CGWindowID: Int]()
|
||||
for (index, cgWindowId) in Spaces.windowsInSpaces([Spaces.currentSpaceId]).enumerated() {
|
||||
windowLevelMap[cgWindowId] = index
|
||||
}
|
||||
var sortedTuples = Windows.listRecentlyUsedFirst.map { (windowLevelMap[$0.cgWindowId], $0) }
|
||||
sortedTuples.sort(by: {
|
||||
if $0.0 == nil {
|
||||
return false
|
||||
}
|
||||
if $1.0 == nil {
|
||||
return true
|
||||
}
|
||||
return $0.0! < $1.0!
|
||||
})
|
||||
Windows.listRecentlyUsedFirst = sortedTuples.map { $0.1 }
|
||||
}
|
||||
|
||||
static func refreshAllThumbnails() {
|
||||
for window in listRecentlyUsedFirst {
|
||||
window.refreshThumbnail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == Window {
|
||||
func firstIndexThatMatches(_ element: AXUIElement) -> Self.Index? {
|
||||
// `CFEqual` is safer than comparing `CGWindowID` because it will succeed even if the window is deallocated
|
||||
// by the OS, in which case the `CGWindowID` will be `-1`
|
||||
return firstIndex(where: { CFEqual($0.axUiElement, element) })
|
||||
}
|
||||
|
||||
func firstWindowThatMatches(_ element: AXUIElement) -> Window? {
|
||||
guard let index = firstIndexThatMatches(element) else { return nil }
|
||||
return self[index]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import AppKit
|
||||
|
||||
autoreleasepool {
|
||||
Application.shared.run()
|
||||
App.shared.run()
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Cocoa
|
||||
|
||||
let cgsMainConnectionId = CGSMainConnectionID()
|
||||
|
||||
class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
||||
class App: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
||||
static let name = "AltTab"
|
||||
var statusItem: NSStatusItem?
|
||||
var thumbnailsPanel: ThumbnailsPanel?
|
||||
@@ -27,13 +27,12 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
||||
Preferences.loadFromDiskAndUpdateValues()
|
||||
statusItem = StatusItem.make(self)
|
||||
initPreferencesDependentComponents()
|
||||
Spaces.updateInitialSpace()
|
||||
Applications.addInitialRunningApplications()
|
||||
Applications.observeRunningApplications()
|
||||
Spaces.observeSpaceChanges()
|
||||
Windows.sortByLevel()
|
||||
Keyboard.listenToGlobalEvents(self)
|
||||
warmUpThumbnailPanel()
|
||||
}
|
||||
|
||||
// running this code on startup avoid having the very first invocation be slow for the user
|
||||
private func warmUpThumbnailPanel() {
|
||||
thumbnailsPanel!.computeThumbnails(Screen.preferred())
|
||||
}
|
||||
|
||||
// we put application code here which should be executed on init() and Preferences change
|
||||
@@ -52,7 +51,8 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
||||
debugPrint("focusTarget")
|
||||
if appIsBeingUsed {
|
||||
debugPrint("focusTarget: appIsBeingUsed")
|
||||
focusSelectedWindow(TrackedWindows.focusedWindow())
|
||||
let window = Windows.focusedWindow()
|
||||
focusSelectedWindow(window)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
||||
if preferencesPanel == nil {
|
||||
preferencesPanel = PreferencesPanel()
|
||||
}
|
||||
Screen.showPanel(preferencesPanel!, Screen.preferred(), .appleCentered)
|
||||
Screen.repositionPanel(preferencesPanel!, Screen.preferred(), .appleCentered)
|
||||
Screen.showPanel(preferencesPanel!)
|
||||
}
|
||||
|
||||
@objc
|
||||
@@ -71,8 +72,8 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
||||
}
|
||||
|
||||
func cycleSelection(_ step: Int) {
|
||||
TrackedWindows.focusedWindowIndex = TrackedWindows.moveFocusedWindowIndex(step)
|
||||
self.thumbnailsPanel!.highlightCellAt(step)
|
||||
Windows.cycleFocusedWindowIndex(step)
|
||||
thumbnailsPanel!.highlightCell()
|
||||
}
|
||||
|
||||
func showUiOrCycleSelection(_ step: Int) {
|
||||
@@ -81,25 +82,36 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
|
||||
if isFirstSummon {
|
||||
debugPrint("showUiOrCycleSelection: isFirstSummon")
|
||||
isFirstSummon = false
|
||||
TrackedWindows.refreshList(step)
|
||||
if TrackedWindows.list.count == 0 {
|
||||
if Windows.listRecentlyUsedFirst.count == 0 {
|
||||
appIsBeingUsed = false
|
||||
isFirstSummon = true
|
||||
return
|
||||
}
|
||||
TrackedWindows.focusedWindowIndex = TrackedWindows.moveFocusedWindowIndex(step)
|
||||
let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen)
|
||||
if uiWorkShouldBeDone { self.thumbnailsPanel!.computeThumbnails(currentScreen); debugPrint("computeThumbnails") }
|
||||
if uiWorkShouldBeDone { self.thumbnailsPanel!.highlightCellAt(step); debugPrint("highlightCellAt") }
|
||||
if uiWorkShouldBeDone { Screen.showPanel(self.thumbnailsPanel!, currentScreen, .appleCentered); debugPrint("showPanel") }
|
||||
// TODO: find a way to update isSingleSpace by listening to space creation, instead of on every trigger
|
||||
Spaces.updateIsSingleSpace()
|
||||
// TODO: find a way to update space index when windows are moved to another space, instead of on every trigger
|
||||
Windows.updateSpaces()
|
||||
// TODO: find a way to update thumbnails by listening to content change, instead of every trigger. Or better, switch to video
|
||||
Windows.refreshAllThumbnails()
|
||||
Windows.focusedWindowIndex = 0
|
||||
Windows.cycleFocusedWindowIndex(step)
|
||||
refreshOpenUi()
|
||||
Screen.showPanel(thumbnailsPanel!)
|
||||
} else {
|
||||
debugPrint("showUiOrCycleSelection: !isFirstSummon")
|
||||
cycleSelection(step)
|
||||
}
|
||||
}
|
||||
|
||||
func focusSelectedWindow(_ window: TrackedWindow?) {
|
||||
func refreshOpenUi() {
|
||||
let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen)
|
||||
if uiWorkShouldBeDone { thumbnailsPanel!.refreshCollectionView(currentScreen); debugPrint("refreshCollectionView") }
|
||||
if uiWorkShouldBeDone { thumbnailsPanel!.highlightCell(); debugPrint("highlightCellAt") }
|
||||
if uiWorkShouldBeDone { Screen.repositionPanel(thumbnailsPanel!, currentScreen, .appleCentered); debugPrint("showPanel") }
|
||||
}
|
||||
|
||||
func focusSelectedWindow(_ window: Window?) {
|
||||
hideUi()
|
||||
DispatchQueue.global(qos: .userInteractive).async { window?.focus() }
|
||||
window?.focus()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Cocoa
|
||||
import WebKit
|
||||
|
||||
typealias MouseDownCallback = (TrackedWindow) -> Void
|
||||
typealias MouseDownCallback = (Window) -> Void
|
||||
typealias MouseMovedCallback = (Cell) -> Void
|
||||
|
||||
class Cell: NSCollectionViewItem {
|
||||
@@ -11,7 +11,7 @@ class Cell: NSCollectionViewItem {
|
||||
var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white)
|
||||
var hiddenIcon = FontIcon(FontIcon.sfSymbolCircledDotSign, Preferences.fontIconSize, .white)
|
||||
var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white)
|
||||
var openWindow: TrackedWindow?
|
||||
var openWindow: Window?
|
||||
var mouseDownCallback: MouseDownCallback?
|
||||
var mouseMovedCallback: MouseMovedCallback?
|
||||
|
||||
@@ -39,7 +39,7 @@ class Cell: NSCollectionViewItem {
|
||||
}
|
||||
}
|
||||
|
||||
func updateWithNewContent(_ element: TrackedWindow, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) {
|
||||
func updateWithNewContent(_ element: Window, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) {
|
||||
openWindow = element
|
||||
thumbnail.image = element.thumbnail
|
||||
let (width, height) = Cell.computeDownscaledSize(element.thumbnail, screen)
|
||||
@@ -53,7 +53,7 @@ class Cell: NSCollectionViewItem {
|
||||
label.font = Preferences.font!
|
||||
hiddenIcon.isHidden = !openWindow!.isHidden
|
||||
minimizedIcon.isHidden = !openWindow!.isMinimized
|
||||
spaceIcon.isHidden = element.spaceIndex == nil || Spaces.singleSpace || Preferences.hideSpaceNumberLabels
|
||||
spaceIcon.isHidden = element.spaceIndex == nil || Spaces.isSingleSpace || Preferences.hideSpaceNumberLabels
|
||||
if !spaceIcon.isHidden {
|
||||
spaceIcon.setNumber(UInt32(element.spaceIndex!))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class PreferencesPanel: NSPanel, NSWindowDelegate {
|
||||
override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) {
|
||||
let initialRect = NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight)
|
||||
super.init(contentRect: initialRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||
title = Application.name + " Preferences"
|
||||
title = App.name + " Preferences"
|
||||
hidesOnDeactivate = false
|
||||
contentView = makeContentView()
|
||||
}
|
||||
@@ -201,7 +201,7 @@ class PreferencesPanel: NSPanel, NSWindowDelegate {
|
||||
throw NSError.make(domain: "Preferences", message: "Please enter a valid value for '" + key + "'")
|
||||
}
|
||||
try Preferences.updateAndValidateFromString(key, newValue)
|
||||
(NSApp as! Application).initPreferencesDependentComponents()
|
||||
(NSApp as! App).initPreferencesDependentComponents()
|
||||
try Preferences.saveRawToDisk()
|
||||
} catch let error {
|
||||
debugPrint("PreferencesPanel: save: error", key, newValue, error)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import Cocoa
|
||||
|
||||
class StatusItem {
|
||||
static func make(_ application: Application) -> NSStatusItem {
|
||||
static func make(_ app: App) -> NSStatusItem {
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
item.button!.title = Application.name
|
||||
item.button!.title = App.name
|
||||
item.menu = NSMenu()
|
||||
item.menu!.addItem(
|
||||
withTitle: "Show",
|
||||
action: #selector(application.showUi),
|
||||
action: #selector(app.showUi),
|
||||
keyEquivalent: "s"
|
||||
)
|
||||
item.menu!.addItem(
|
||||
withTitle: "Preferences…",
|
||||
action: #selector(application.showPreferencesPanel),
|
||||
action: #selector(app.showPreferencesPanel),
|
||||
keyEquivalent: ",")
|
||||
item.menu!.addItem(
|
||||
withTitle: "Quit \(Application.name) #VERSION#",
|
||||
withTitle: "Quit \(App.name) #VERSION#",
|
||||
action: #selector(NSApplication.terminate(_:)),
|
||||
keyEquivalent: "q")
|
||||
return item
|
||||
|
||||
@@ -3,7 +3,7 @@ import Cocoa
|
||||
class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout {
|
||||
var backgroundView: NSVisualEffectView?
|
||||
var collectionView_: NSCollectionView!
|
||||
var application: Application?
|
||||
var app: App?
|
||||
let cellId = NSUserInterfaceItemIdentifier("Cell")
|
||||
var currentScreen: NSScreen?
|
||||
|
||||
@@ -11,9 +11,9 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele
|
||||
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||
}
|
||||
|
||||
convenience init(_ application: Application) {
|
||||
convenience init(_ app: App) {
|
||||
self.init()
|
||||
self.application = application
|
||||
self.app = app
|
||||
isFloatingPanel = true
|
||||
animationBehavior = .none
|
||||
hidesOnDeactivate = false
|
||||
@@ -26,6 +26,8 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele
|
||||
contentView!.addSubview(backgroundView!)
|
||||
// highest level possible; this allows the app to go on top of context menus
|
||||
level = .screenSaver
|
||||
// helps filter out this window from the thumbnails
|
||||
setAccessibilitySubrole(.unknown)
|
||||
}
|
||||
|
||||
private func makeBackgroundView() -> NSVisualEffectView {
|
||||
@@ -60,38 +62,38 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return TrackedWindows.list.count
|
||||
return Windows.listRecentlyUsedFirst.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
|
||||
let item = collectionView.makeItem(withIdentifier: cellId, for: indexPath) as! Cell
|
||||
item.updateWithNewContent(TrackedWindows.list[indexPath.item], application!.focusSelectedWindow, application!.thumbnailsPanel!.highlightCell, currentScreen!)
|
||||
item.updateWithNewContent(Windows.listRecentlyUsedFirst[indexPath.item], app!.focusSelectedWindow, app!.thumbnailsPanel!.highlightCell, currentScreen!)
|
||||
return item
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize {
|
||||
if indexPath.item < TrackedWindows.list.count {
|
||||
let (width, height) = Cell.computeDownscaledSize(TrackedWindows.list[indexPath.item].thumbnail, currentScreen!)
|
||||
if indexPath.item < Windows.listRecentlyUsedFirst.count {
|
||||
let (width, height) = Cell.computeDownscaledSize(Windows.listRecentlyUsedFirst[indexPath.item].thumbnail, currentScreen!)
|
||||
return NSSize(width: CGFloat(width) + Preferences.cellPadding * 2, height: CGFloat(height) + max(Preferences.fontHeight!, Preferences.iconSize!) + Preferences.interItemPadding + Preferences.cellPadding * 2)
|
||||
}
|
||||
return .zero
|
||||
}
|
||||
|
||||
func highlightCellAt(_ step: Int) {
|
||||
collectionView_!.selectItems(at: [IndexPath(item: TrackedWindows.focusedWindowIndex, section: 0)], scrollPosition: .top)
|
||||
collectionView_!.deselectItems(at: [IndexPath(item: TrackedWindows.moveFocusedWindowIndex(-step), section: 0)])
|
||||
func highlightCell() {
|
||||
collectionView_.deselectAll(nil)
|
||||
collectionView_!.selectItems(at: [IndexPath(item: Windows.focusedWindowIndex, section: 0)], scrollPosition: .top)
|
||||
}
|
||||
|
||||
func highlightCell(_ cell: Cell) {
|
||||
let newIndex = collectionView_.indexPath(for: cell)!
|
||||
if TrackedWindows.focusedWindowIndex != newIndex.item {
|
||||
if Windows.focusedWindowIndex != newIndex.item {
|
||||
collectionView_!.selectItems(at: [newIndex], scrollPosition: .top)
|
||||
collectionView_!.deselectItems(at: [IndexPath(item: TrackedWindows.focusedWindowIndex, section: 0)])
|
||||
TrackedWindows.focusedWindowIndex = newIndex.item
|
||||
collectionView_!.deselectItems(at: [IndexPath(item: Windows.focusedWindowIndex, section: 0)])
|
||||
Windows.focusedWindowIndex = newIndex.item
|
||||
}
|
||||
}
|
||||
|
||||
func computeThumbnails(_ currentScreen: NSScreen) {
|
||||
func refreshCollectionView(_ currentScreen: NSScreen) {
|
||||
self.currentScreen = currentScreen
|
||||
(collectionView_.collectionViewLayout as! CollectionViewCenterFlowLayout).currentScreen = currentScreen
|
||||
collectionView_!.setFrameSize(Screen.thumbnailPanelMaxSize(currentScreen))
|
||||
|
||||
Reference in new issue
Block a user