From 9f86a655798407c50c6a6c051139a21d0c38377b Mon Sep 17 00:00:00 2001 From: "louis.pontoise" <> Date: Thu, 9 Jan 2020 01:49:19 +0900 Subject: [PATCH] refactor: complete rework of the internals closes #93 closes #24 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 --- alt-tab-macos.xcodeproj/project.pbxproj | 36 +++-- alt-tab-macos/api-wrappers/AXUIElement.swift | 114 ++++++---------- alt-tab-macos/api-wrappers/CGWindow.swift | 47 ++++--- alt-tab-macos/api-wrappers/CGWindowID.swift | 17 ++- alt-tab-macos/api-wrappers/PrivateApis.swift | 48 +++---- alt-tab-macos/logic/Application.swift | 99 ++++++++++++++ alt-tab-macos/logic/Applications.swift | 67 ++++++++++ alt-tab-macos/logic/DispatchQueues.swift | 6 + alt-tab-macos/logic/Keyboard.swift | 35 +++-- alt-tab-macos/logic/Screen.swift | 2 +- alt-tab-macos/logic/Spaces.swift | 33 ++++- alt-tab-macos/logic/SystemPermissions.swift | 2 +- alt-tab-macos/logic/TrackedWindow.swift | 47 ------- alt-tab-macos/logic/TrackedWindows.swift | 105 --------------- alt-tab-macos/logic/Window.swift | 123 ++++++++++++++++++ alt-tab-macos/logic/Windows.swift | 53 ++++++++ alt-tab-macos/main.swift | 2 +- .../ui/{Application.swift => App.swift} | 25 ++-- alt-tab-macos/ui/Cell.swift | 8 +- alt-tab-macos/ui/PreferencesPanel.swift | 4 +- alt-tab-macos/ui/StatusItem.swift | 10 +- alt-tab-macos/ui/ThumbnailsPanel.swift | 29 +++-- 22 files changed, 572 insertions(+), 340 deletions(-) create mode 100644 alt-tab-macos/logic/Application.swift create mode 100644 alt-tab-macos/logic/Applications.swift create mode 100644 alt-tab-macos/logic/DispatchQueues.swift delete mode 100644 alt-tab-macos/logic/TrackedWindow.swift delete mode 100644 alt-tab-macos/logic/TrackedWindows.swift create mode 100644 alt-tab-macos/logic/Window.swift create mode 100644 alt-tab-macos/logic/Windows.swift rename alt-tab-macos/ui/{Application.swift => App.swift} (78%) diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index fab848a4a..d1ef77b6a 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 a079218ef..8ae934589 100644 --- a/alt-tab-macos/api-wrappers/AXUIElement.swift +++ b/alt-tab-macos/api-wrappers/AXUIElement.swift @@ -1,100 +1,68 @@ 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? { - if let a = attribute(key, AXValue.self) { - var value = target - AXValueGetValue(a, type, &value) - return value - } - 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 { + func cgWindowId() -> CGWindowID { var id = CGWindowID(0) _AXUIElementGetWindow(self, &id) return id } - func focusedWindow() -> AXUIElement? { - return attribute(.focusedWindow, AXUIElement.self) + func pid() -> pid_t { + var pid = pid_t(0) + AXUIElementGetPid(self, &pid) + return pid } - func isActualWindow() -> Bool { - let subrole = self.attribute(.subrole, String.self) - return subrole != nil && subrole != "AXUnknown" + 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 windows() -> [AXUIElement]? { - return attribute(.windows, [AXUIElement].self) + func title() -> String? { + return attribute(kAXTitleAttribute, String.self) } - func window(_ id: CGWindowID) -> AXUIElement? { - return windows()?.first(where: { return id == $0.cgId() }) + func windows() -> [AXUIElement]? { + return attribute(kAXWindowsAttribute, [AXUIElement].self) } func isMinimized() -> Bool { - return attribute(.minimized, Bool.self) == true + return attribute(kAXMinimizedAttribute, 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) + func isHidden() -> Bool { + return attribute(kAXHiddenAttribute, Bool.self) == true } - // 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 + func focusedWindow() -> AXUIElement? { + return attribute(kAXFocusedWindowAttribute, AXUIElement.self) + } - var bytes2 = [UInt8](repeating: 0, count: 0xf8) - bytes2[0x04] = 0xF8 - bytes2[0x08] = 0x02 - bytes2[0x3a] = 0x10 + func subrole() -> String? { + return attribute(kAXSubroleAttribute, String.self) + } - memcpy(&bytes1[0x3c], &wid_, MemoryLayout.size) - memset(&bytes1[0x20], 0xFF, 0x10) - memcpy(&bytes2[0x3c], &wid_, MemoryLayout.size) - memset(&bytes2[0x20], 0xFF, 0x10) + 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 + } - SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes1)).pointee)) - SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes2)).pointee)) + private func value(_ key: String, _ target: T, _ type: AXValueType) -> T? { + if let a = attribute(key, AXValue.self) { + var value = target + AXValueGetValue(a, type, &value) + return value + } + return nil } } diff --git a/alt-tab-macos/api-wrappers/CGWindow.swift b/alt-tab-macos/api-wrappers/CGWindow.swift index 964f166be..ed8b47f84 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 f6d4bee93..e416363dd 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 98809701a..79da6de57 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 000000000..1ddde670a --- /dev/null +++ b/alt-tab-macos/logic/Application.swift @@ -0,0 +1,99 @@ +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 { + self.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 } + addAndObserveWindows() + runningApplication.removeObserver(self, forKeyPath: "isFinishedLaunching") + } + + 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) + // TODO: when using the global queue, sometimes it breaks the `runningApplications` observation somehow + // To reproduce, open Terminal, quit it, open, quit, repeat. After a while the .insertion and .removal events will stop happening +// DispatchQueue.global(qos: .userInteractive).async { + CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode) +// CFRunLoopRun() +// } + } +} + +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()) + DispatchQueue.main.async { + switch type { + case kAXApplicationActivatedNotification: + guard 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 + } + case kAXWindowCreatedNotification: + guard element.isActualWindow() else { return } + // a window being un-minimized can trigger kAXWindowCreatedNotification + guard Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) == nil else { return } + Windows.listRecentlyUsedFirst.insert(Window(element, application), at: 0) + case kAXFocusedWindowChangedNotification: + guard element.isActualWindow() else { return } + guard 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 000000000..6a9e238d3 --- /dev/null +++ b/alt-tab-macos/logic/Applications.swift @@ -0,0 +1,67 @@ +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 } + Windows.listRecentlyUsedFirst.removeAll(where: { $0.application.runningApplication.processIdentifier == runningApp.processIdentifier }) + Applications.map.removeValue(forKey: runningApp.processIdentifier) + } + // 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 000000000..7b812b7e8 --- /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 eef14de41..1382af243 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 @@ -50,18 +47,18 @@ func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, let isEscape = event.keyCode == kVK_Escape 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 type == .keyDown && isEscape { - return dispatchWork(application, false, { application.hideUi() }) + return dispatchWork(app, false, { app.hideUi() }) } } 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 41c857326..13fcacfd9 100644 --- a/alt-tab-macos/logic/Screen.swift +++ b/alt-tab-macos/logic/Screen.swift @@ -34,7 +34,7 @@ class Screen { let y = screenFrame.minY + max(screenFrame.height - panelFrame.height, 0) * alignment.rawValue panel.setFrameOrigin(NSPoint(x: x, y: y)) 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 9a144f616..4f4a470d6 100644 --- a/alt-tab-macos/logic/Spaces.swift +++ b/alt-tab-macos/logic/Spaces.swift @@ -2,9 +2,30 @@ 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 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]) @@ -12,6 +33,16 @@ class Spaces { .map { (($0.element as! NSDictionary)["id64"]! as! CGSSpaceID, $0.offset + 1) } } + static func isSingleSpace() -> Bool { + guard Windows.listRecentlyUsedFirst.count > 0 else { return true } + let firstSpaceIndex = Windows.listRecentlyUsedFirst.first!.spaceIndex + for window in Windows.listRecentlyUsedFirst { + guard window.spaceIndex != firstSpaceIndex else { continue } + return false + } + return true + } + static func windowsInSpaces(_ spaceIds: [CGSSpaceID]) -> [CGWindowID] { var set_tags = UInt64(0) var clear_tags = UInt64(0) diff --git a/alt-tab-macos/logic/SystemPermissions.swift b/alt-tab-macos/logic/SystemPermissions.swift index a7b67615f..6e716cae6 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 cb35fe5e4..000000000 --- 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 ac00634c6..000000000 --- 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 000000000..3564160a6 --- /dev/null +++ b/alt-tab-macos/logic/Window.swift @@ -0,0 +1,123 @@ +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) + DispatchQueue.global(qos: .userInteractive).async { + CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver), .defaultMode) + CFRunLoopRun() + } + } + + 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()) + DispatchQueue.main.async { + switch type { + case kAXUIElementDestroyedNotification: + guard let existingIndex = Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) else { return } + Windows.listRecentlyUsedFirst.remove(at: existingIndex) + case kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification: + guard let existingIndex = Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) else { return } + Windows.listRecentlyUsedFirst[existingIndex].isMinimized = type == kAXWindowMiniaturizedNotification + case kAXTitleChangedNotification: + guard element.isActualWindow() else { return } + guard let existingIndex = Windows.listRecentlyUsedFirst.firstIndexThatMatches(element) else { return } + let window = Windows.listRecentlyUsedFirst[existingIndex] + window.title = window.axUiElement.title()! + default: return + } + } +} diff --git a/alt-tab-macos/logic/Windows.swift b/alt-tab-macos/logic/Windows.swift new file mode 100644 index 000000000..ecb13c80c --- /dev/null +++ b/alt-tab-macos/logic/Windows.swift @@ -0,0 +1,53 @@ +import Cocoa +import Foundation + +class Windows { + static var listRecentlyUsedFirst = [Window]() + static var focusedWindowIndex = Int(0) + + static func focusedWindow() -> Window? { + return listRecentlyUsedFirst.count > focusedWindowIndex ? listRecentlyUsedFirst[focusedWindowIndex] : nil + } + + static func moveFocusedWindowIndex(_ step: Int) -> Int { + return focusedWindowIndex + step < 0 ? listRecentlyUsedFirst.count - 1 : (focusedWindowIndex + step) % listRecentlyUsedFirst.count + } + + 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 } + } +} + +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) }) + } +} + +typealias WindowRank = Int +typealias WindowsMap = [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)] \ No newline at end of file diff --git a/alt-tab-macos/main.swift b/alt-tab-macos/main.swift index b2c44a5b0..2a84c6fe5 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 78% rename from alt-tab-macos/ui/Application.swift rename to alt-tab-macos/ui/App.swift index 15d5e6bc7..ff963097a 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,8 +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 @@ -52,7 +56,8 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { debugPrint("focusTarget") if appIsBeingUsed { debugPrint("focusTarget: appIsBeingUsed") - focusSelectedWindow(TrackedWindows.focusedWindow()) + let window = Windows.focusedWindow() + focusSelectedWindow(window) } } @@ -71,7 +76,7 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { } func cycleSelection(_ step: Int) { - TrackedWindows.focusedWindowIndex = TrackedWindows.moveFocusedWindowIndex(step) + Windows.focusedWindowIndex = Windows.moveFocusedWindowIndex(step) self.thumbnailsPanel!.highlightCellAt(step) } @@ -81,13 +86,15 @@ 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) + Windows.focusedWindowIndex = 0 + Windows.focusedWindowIndex = Windows.moveFocusedWindowIndex(step) + // TODO: find a way to update space index when windows are moved to another space, instead of on every trigger + Windows.updateSpaces() 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") } @@ -98,8 +105,8 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { } } - func focusSelectedWindow(_ window: TrackedWindow?) { + 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 6bcb753f6..a895ef5be 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 902ebe4d9..d65c9c359 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 27995e4ba..6071d47f4 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 9cf88b466..e953952d7 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,41 @@ 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)]) + collectionView_!.selectItems(at: [IndexPath(item: Windows.focusedWindowIndex, section: 0)], scrollPosition: .top) + collectionView_!.deselectItems(at: [IndexPath(item: Windows.moveFocusedWindowIndex(-step), section: 0)]) } 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) { + for window in Windows.listRecentlyUsedFirst { + window.refreshThumbnail() + } self.currentScreen = currentScreen (collectionView_.collectionViewLayout as! CollectionViewCenterFlowLayout).currentScreen = currentScreen collectionView_!.setFrameSize(Screen.thumbnailPanelMaxSize(currentScreen))