diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index fab848a4..d1ef77b6 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -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 = ""; }; D04BA51D43775E57CE91154A /* 3 windows - 1 line - wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line - wide window.jpg"; sourceTree = ""; }; D04BA56355579F78776E6D51 /* Cell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cell.swift; sourceTree = ""; }; + D04BA56E285C3FCDA52ED262 /* DispatchQueues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DispatchQueues.swift; sourceTree = ""; }; D04BA5ABFA5457A86536E2E4 /* 5 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 1 line.jpg"; sourceTree = ""; }; D04BA5EB5ED248C8C22CC672 /* Spaces.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spaces.swift; sourceTree = ""; }; + D04BA66B5B4143D2238F50B9 /* Applications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Applications.swift; sourceTree = ""; }; D04BA78E3B4E73B40DB77174 /* CGWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindow.swift; sourceTree = ""; }; D04BA7B6AAB0812631BBC7A2 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.info; path = Info.plist; sourceTree = ""; }; D04BA7ECCE728582D9ECA613 /* determine_version.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = determine_version.sh; sourceTree = ""; }; 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 = ""; }; - D04BA90C6C36DB1D65BC2B66 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + D04BA90C6C36DB1D65BC2B66 /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; D04BA92541D46EA4F6943A72 /* package-lock.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "package-lock.json"; sourceTree = ""; }; D04BA9EF65B2E7AF9E3ADCA3 /* 2 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2 windows - 1 line.jpg"; sourceTree = ""; }; D04BAA34E0CB00DED7C04B4F /* 2-rows.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2-rows.jpg"; sourceTree = ""; }; @@ -75,7 +80,7 @@ D04BAC02D60EF22D9CC7D969 /* commitlint.config.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = commitlint.config.js; sourceTree = ""; }; D04BAC159731F80FDAF4EA6C /* 1-row.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "1-row.jpg"; sourceTree = ""; }; D04BAC6AFC7F06D1A567F27A /* set_version_in_app.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = set_version_in_app.sh; sourceTree = ""; }; - D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackedWindows.swift; sourceTree = ""; }; + D04BAD1BED44EAEB77FED8A4 /* Windows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = ""; }; D04BAD1C9F215BCCD3B620AC /* alt_tab_macos.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = alt_tab_macos.entitlements; sourceTree = ""; }; D04BAD32E130E4A061DC8332 /* Labels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Labels.swift; sourceTree = ""; }; D04BAD40CE2D3A8AAC3819D0 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = file.gitignore; path = .gitignore; sourceTree = ""; }; @@ -85,11 +90,12 @@ D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D04BAE1243C9B4BE3ED1B524 /* 7 windows - 2 lines - extra wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - extra wide window.jpg"; sourceTree = ""; }; D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsPanel.swift; sourceTree = ""; }; - D04BAE80772D25834E440975 /* TrackedWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackedWindow.swift; sourceTree = ""; }; + D04BAE80772D25834E440975 /* Window.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Window.swift; sourceTree = ""; }; D04BAEA3EDC4F80FA23DBEC4 /* CGWindowID.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindowID.swift; sourceTree = ""; }; D04BAED53465957807CBF8B2 /* FontIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FontIcon.swift; sourceTree = ""; }; D04BAF076A30A1BAFEDBEA66 /* 5 windows - 2 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 2 lines.jpg"; sourceTree = ""; }; D04BAF0DFC1F44322973CE1E /* PrivateApis.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrivateApis.swift; sourceTree = ""; }; + D04BAF13DFAA6930676D0492 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; D04BAF249324297C07E31164 /* frontpage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = frontpage.jpg; sourceTree = ""; }; D04BAFA277EAE3BDDDB61110 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HyperlinkLabel.swift; sourceTree = ""; }; @@ -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 = ""; @@ -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; }; diff --git a/alt-tab-macos/api-wrappers/AXUIElement.swift b/alt-tab-macos/api-wrappers/AXUIElement.swift index a079218e..8ae93458 100644 --- a/alt-tab-macos/api-wrappers/AXUIElement.swift +++ b/alt-tab-macos/api-wrappers/AXUIElement.swift @@ -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(_ 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(_ 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(_ 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(_ 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.size) - memset(&bytes1[0x20], 0xFF, 0x10) - memcpy(&bytes2[0x3c], &wid_, MemoryLayout.size) - memset(&bytes2[0x20], 0xFF, 0x10) - - SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes1)).pointee)) - SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes2)).pointee)) - } } diff --git a/alt-tab-macos/api-wrappers/CGWindow.swift b/alt-tab-macos/api-wrappers/CGWindow.swift index 964f166b..ed8b47f8 100644 --- a/alt-tab-macos/api-wrappers/CGWindow.swift +++ b/alt-tab-macos/api-wrappers/CGWindow.swift @@ -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(_ 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(_ key: CFString, _ type: T.Type) -> T? { + return self[key] as? T + } } diff --git a/alt-tab-macos/api-wrappers/CGWindowID.swift b/alt-tab-macos/api-wrappers/CGWindowID.swift index f6d4bee9..e416363d 100644 --- a/alt-tab-macos/api-wrappers/CGWindowID.swift +++ b/alt-tab-macos/api-wrappers/CGWindowID.swift @@ -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(_ key: String, _ type: T.Type) -> T? { + var value: AnyObject? + CGSCopyWindowProperty(cgsMainConnectionId, self, key as CFString, &value) + return value as? T + } } //class Testt { diff --git a/alt-tab-macos/api-wrappers/PrivateApis.swift b/alt-tab-macos/api-wrappers/PrivateApis.swift index 98809701..79da6de5 100644 --- a/alt-tab-macos/api-wrappers/PrivateApis.swift +++ b/alt-tab-macos/api-wrappers/PrivateApis.swift @@ -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 diff --git a/alt-tab-macos/logic/Application.swift b/alt-tab-macos/logic/Application.swift new file mode 100644 index 00000000..751beff9 --- /dev/null +++ b/alt-tab-macos/logic/Application.swift @@ -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.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 + } +} diff --git a/alt-tab-macos/logic/Applications.swift b/alt-tab-macos/logic/Applications.swift new file mode 100644 index 00000000..90752981 --- /dev/null +++ b/alt-tab-macos/logic/Applications.swift @@ -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 + } + } +} diff --git a/alt-tab-macos/logic/DispatchQueues.swift b/alt-tab-macos/logic/DispatchQueues.swift new file mode 100644 index 00000000..7b812b7e --- /dev/null +++ b/alt-tab-macos/logic/DispatchQueues.swift @@ -0,0 +1,6 @@ +import Foundation + +class DispatchQueues { + static let focusActions = DispatchQueue(label: "focusActions", qos: .userInteractive) + static let keyboardEvents = DispatchQueue(label: "keyboardEvents", qos: .userInteractive) +} diff --git a/alt-tab-macos/logic/Keyboard.swift b/alt-tab-macos/logic/Keyboard.swift index fed480c7..e7287da6 100644 --- a/alt-tab-macos/logic/Keyboard.swift +++ b/alt-tab-macos/logic/Keyboard.swift @@ -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? { +func dispatchWork(_ application: App, _ uiWorkShouldBeDone: Bool, _ fn: @escaping () -> Void) -> Unmanaged? { 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? { - let application = Unmanaged.fromOpaque(delegate_!).takeUnretainedValue() +func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, appPointer: UnsafeMutableRawPointer?) -> Unmanaged? { + let app = Unmanaged.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 { diff --git a/alt-tab-macos/logic/Screen.swift b/alt-tab-macos/logic/Screen.swift index 41c85732..210f1f29 100644 --- a/alt-tab-macos/logic/Screen.swift +++ b/alt-tab-macos/logic/Screen.swift @@ -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 { diff --git a/alt-tab-macos/logic/Spaces.swift b/alt-tab-macos/logic/Spaces.swift index 9a144f61..61a7ace3 100644 --- a/alt-tab-macos/logic/Spaces.swift +++ b/alt-tab-macos/logic/Spaces.swift @@ -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 diff --git a/alt-tab-macos/logic/SystemPermissions.swift b/alt-tab-macos/logic/SystemPermissions.swift index a7b67615..6e716cae 100644 --- a/alt-tab-macos/logic/SystemPermissions.swift +++ b/alt-tab-macos/logic/SystemPermissions.swift @@ -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-", diff --git a/alt-tab-macos/logic/TrackedWindow.swift b/alt-tab-macos/logic/TrackedWindow.swift deleted file mode 100644 index cb35fe5e..00000000 --- a/alt-tab-macos/logic/TrackedWindow.swift +++ /dev/null @@ -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) - } -} diff --git a/alt-tab-macos/logic/TrackedWindows.swift b/alt-tab-macos/logic/TrackedWindows.swift deleted file mode 100644 index ac00634c..00000000 --- a/alt-tab-macos/logic/TrackedWindows.swift +++ /dev/null @@ -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)] \ No newline at end of file diff --git a/alt-tab-macos/logic/Window.swift b/alt-tab-macos/logic/Window.swift new file mode 100644 index 00000000..aa0a841b --- /dev/null +++ b/alt-tab-macos/logic/Window.swift @@ -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.size) + memset(&bytes1[0x20], 0xFF, 0x10) + memcpy(&bytes2[0x3c], &cgWindowId, MemoryLayout.size) + memset(&bytes2[0x20], 0xFF, 0x10) + + SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes1)).pointee)) + SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(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 + } +} diff --git a/alt-tab-macos/logic/Windows.swift b/alt-tab-macos/logic/Windows.swift new file mode 100644 index 00000000..e89bb088 --- /dev/null +++ b/alt-tab-macos/logic/Windows.swift @@ -0,0 +1,72 @@ +import Cocoa +import Foundation + +class Windows { + static var listRecentlyUsedFirst = [Window]() + static var focusedWindowIndex = Array.Index(0) + + static func focusedWindow() -> Window? { + return listRecentlyUsedFirst.count > focusedWindowIndex ? listRecentlyUsedFirst[focusedWindowIndex] : nil + } + + static func cycleFocusedWindowIndex(_ step: Array.Index) { + focusedWindowIndex = focusedWindowIndex + step < 0 ? listRecentlyUsedFirst.count - 1 : (focusedWindowIndex + step) % listRecentlyUsedFirst.count + } + + static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ destroyedWindowIndex: Array.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] + } +} diff --git a/alt-tab-macos/main.swift b/alt-tab-macos/main.swift index b2c44a5b..2a84c6fe 100644 --- a/alt-tab-macos/main.swift +++ b/alt-tab-macos/main.swift @@ -1,5 +1,5 @@ import AppKit autoreleasepool { - Application.shared.run() + App.shared.run() } diff --git a/alt-tab-macos/ui/Application.swift b/alt-tab-macos/ui/App.swift similarity index 53% rename from alt-tab-macos/ui/Application.swift rename to alt-tab-macos/ui/App.swift index 15d5e6bc..042d42b0 100644 --- a/alt-tab-macos/ui/Application.swift +++ b/alt-tab-macos/ui/App.swift @@ -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() } } diff --git a/alt-tab-macos/ui/Cell.swift b/alt-tab-macos/ui/Cell.swift index 6bcb753f..746e409f 100644 --- a/alt-tab-macos/ui/Cell.swift +++ b/alt-tab-macos/ui/Cell.swift @@ -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!)) } diff --git a/alt-tab-macos/ui/PreferencesPanel.swift b/alt-tab-macos/ui/PreferencesPanel.swift index 902ebe4d..d65c9c35 100644 --- a/alt-tab-macos/ui/PreferencesPanel.swift +++ b/alt-tab-macos/ui/PreferencesPanel.swift @@ -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) diff --git a/alt-tab-macos/ui/StatusItem.swift b/alt-tab-macos/ui/StatusItem.swift index 27995e4b..6071d47f 100644 --- a/alt-tab-macos/ui/StatusItem.swift +++ b/alt-tab-macos/ui/StatusItem.swift @@ -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 diff --git a/alt-tab-macos/ui/ThumbnailsPanel.swift b/alt-tab-macos/ui/ThumbnailsPanel.swift index 9cf88b46..9411153f 100644 --- a/alt-tab-macos/ui/ThumbnailsPanel.swift +++ b/alt-tab-macos/ui/ThumbnailsPanel.swift @@ -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))