diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 317cd453..fab848a4 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -7,27 +7,35 @@ objects = { /* Begin PBXBuildFile section */ + 4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4807A6C523A9CD190052A53E /* SkyLight.framework */; }; D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */; }; - D04BA0F3D46BC79544E2B930 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA86768C6503A11ED81FC /* Extensions.swift */; }; + D04BA0496ACF1427B6E9D369 /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA78E3B4E73B40DB77174 /* CGWindow.swift */; }; D04BA20D4A240843293B3B52 /* Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA56355579F78776E6D51 /* Cell.swift */; }; - D04BA278D9EFA568C8D18A4C /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1BED44EAEB77FED8A4 /* WindowManager.swift */; }; + D04BA278D9EFA568C8D18A4C /* TrackedWindows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */; }; + D04BA2CBF0EFA04CC80EC1BC /* TrackedWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE80772D25834E440975 /* TrackedWindow.swift */; }; + D04BA308162F8043F8561D03 /* AXUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA40A4291E4F310527DBF /* AXUIElement.swift */; }; D04BA3261C7DA5F48310E654 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA90C6C36DB1D65BC2B66 /* Application.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 */; }; D04BA6368E681BE3A408AC99 /* PreferencesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA02F476DE30C4647886C /* PreferencesPanel.swift */; }; + D04BA6B6B703DCEFE892D5A4 /* Spaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA5EB5ED248C8C22CC672 /* Spaces.swift */; }; D04BA70FF7262BF5F9E6E13B /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */; }; - D04BA89A77CFDC4A3DF30487 /* CoreGraphicsApis.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA7AC3D9153353CE0A76D /* CoreGraphicsApis.swift */; }; + D04BA8373D4DE452C0C081ED /* SF-Pro-Text-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = D04BABC654F40BE74DA25BC7 /* SF-Pro-Text-Regular.otf */; }; D04BA8EBC0365A019A27C7EA /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */; }; 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 */; }; - D04BACD2FFB12589F9286B47 /* AccessibilityApis.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE965ED779513FDEA0CD /* AccessibilityApis.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 */; }; D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAA44C837F3A67403B9DB /* main.swift */; }; F029861A378EC1417106FEC3 /* TextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0298E42A818112B290FF6C7 /* TextField.swift */; }; F0298AB28A3CE5DBEC385730 /* HyperlinkLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 4807A6C523A9CD190052A53E /* SkyLight.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SkyLight.framework; path = ../../../../System/Library/PrivateFrameworks/SkyLight.framework; sourceTree = ""; }; D04BA02F476DE30C4647886C /* PreferencesPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesPanel.swift; sourceTree = ""; }; D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = ""; }; D04BA0CE87BE264C52987ED1 /* 7 windows - 2 lines - wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - wide window.jpg"; sourceTree = ""; }; @@ -43,6 +51,7 @@ D04BA32F25860B686DFE818A /* 3 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line.jpg"; sourceTree = ""; }; D04BA35456DA0DDA74F9687E /* Keyboard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keyboard.swift; sourceTree = ""; }; D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; }; + D04BA40A4291E4F310527DBF /* AXUIElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AXUIElement.swift; sourceTree = ""; }; D04BA4336B6004A0A99849AD /* package.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = package.json; sourceTree = ""; }; D04BA459034C1885CA43A807 /* LICENCE.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = LICENCE.md; sourceTree = ""; }; D04BA4B5292629AA6B560216 /* package_release.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = package_release.sh; sourceTree = ""; }; @@ -50,21 +59,23 @@ 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 = ""; }; D04BA5ABFA5457A86536E2E4 /* 5 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 1 line.jpg"; sourceTree = ""; }; - D04BA7AC3D9153353CE0A76D /* CoreGraphicsApis.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreGraphicsApis.swift; sourceTree = ""; }; + D04BA5EB5ED248C8C22CC672 /* Spaces.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spaces.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; }; - D04BA86768C6503A11ED81FC /* Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; D04BAA44C837F3A67403B9DB /* main.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; D04BAB6652494D7575057E86 /* 14 windows - 3 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "14 windows - 3 lines.jpg"; sourceTree = ""; }; + D04BABC654F40BE74DA25BC7 /* SF-Pro-Text-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file.otf; path = "SF-Pro-Text-Regular.otf"; sourceTree = ""; }; 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 /* WindowManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; + D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackedWindows.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 = ""; }; @@ -74,8 +85,11 @@ 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 = ""; }; - D04BAE965ED779513FDEA0CD /* AccessibilityApis.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessibilityApis.swift; sourceTree = ""; }; + D04BAE80772D25834E440975 /* TrackedWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackedWindow.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 = ""; }; 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 = ""; }; @@ -87,12 +101,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 4807A6C423A9CD190052A53E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4807A6C523A9CD190052A53E /* SkyLight.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; D04BA1463D2A17038222BB84 = { isa = PBXGroup; children = ( @@ -110,6 +133,7 @@ D04BA703DCD38D9757093312 /* ci */, D04BA459034C1885CA43A807 /* LICENCE.md */, D04BA2C9EF33A646D0977195 /* .github */, + 4807A6C423A9CD190052A53E /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +145,18 @@ name = Products; sourceTree = ""; }; + D04BA22D2CA2755FA5902C34 /* api-wrappers */ = { + isa = PBXGroup; + children = ( + D04BA40A4291E4F310527DBF /* AXUIElement.swift */, + D04BA78E3B4E73B40DB77174 /* CGWindow.swift */, + D04BA8F1AA48A323EE5638DC /* HelperExtensions.swift */, + D04BAF0DFC1F44322973CE1E /* PrivateApis.swift */, + D04BAEA3EDC4F80FA23DBEC4 /* CGWindowID.swift */, + ); + path = "api-wrappers"; + sourceTree = ""; + }; D04BA2C9EF33A646D0977195 /* .github */ = { isa = PBXGroup; children = ( @@ -150,6 +186,14 @@ path = "windows-10"; sourceTree = ""; }; + D04BA5A0E9C82F7579CD2B78 /* resources */ = { + isa = PBXGroup; + children = ( + D04BABC654F40BE74DA25BC7 /* SF-Pro-Text-Regular.otf */, + ); + path = resources; + sourceTree = ""; + }; D04BA63877FC8FB11C43C3D2 /* alt-tab-macos */ = { isa = PBXGroup; children = ( @@ -175,13 +219,12 @@ isa = PBXGroup; children = ( D04BA35456DA0DDA74F9687E /* Keyboard.swift */, - D04BAD1BED44EAEB77FED8A4 /* WindowManager.swift */, + D04BAD1BED44EAEB77FED8A4 /* TrackedWindows.swift */, D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */, D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */, D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */, - D04BA86768C6503A11ED81FC /* Extensions.swift */, - D04BAE965ED779513FDEA0CD /* AccessibilityApis.swift */, - D04BA7AC3D9153353CE0A76D /* CoreGraphicsApis.swift */, + D04BA5EB5ED248C8C22CC672 /* Spaces.swift */, + D04BAE80772D25834E440975 /* TrackedWindow.swift */, ); path = logic; sourceTree = ""; @@ -198,6 +241,7 @@ D04BAD32E130E4A061DC8332 /* Labels.swift */, F0298E42A818112B290FF6C7 /* TextField.swift */, F0298708E2B13DBD4738AE76 /* HyperlinkLabel.swift */, + D04BAED53465957807CBF8B2 /* FontIcon.swift */, ); path = ui; sourceTree = ""; @@ -227,6 +271,8 @@ D04BA7B6AAB0812631BBC7A2 /* Info.plist */, D04BAA44C837F3A67403B9DB /* main.swift */, D04BAA1C553891551B903DA7 /* logic */, + D04BA22D2CA2755FA5902C34 /* api-wrappers */, + D04BA5A0E9C82F7579CD2B78 /* resources */, ); path = "alt-tab-macos"; sourceTree = ""; @@ -290,6 +336,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D04BA8373D4DE452C0C081ED /* SF-Pro-Text-Regular.otf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -304,7 +351,7 @@ D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */, D04BA20D4A240843293B3B52 /* Cell.swift in Sources */, D04BA57A871B7269BEBAFF84 /* Keyboard.swift in Sources */, - D04BA278D9EFA568C8D18A4C /* WindowManager.swift in Sources */, + D04BA278D9EFA568C8D18A4C /* TrackedWindows.swift in Sources */, D04BA3261C7DA5F48310E654 /* Application.swift in Sources */, D04BA70FF7262BF5F9E6E13B /* Preferences.swift in Sources */, D04BA6368E681BE3A408AC99 /* PreferencesPanel.swift in Sources */, @@ -312,12 +359,17 @@ D04BA8EBC0365A019A27C7EA /* Screen.swift in Sources */, D04BA9CCE02D30C8164A552A /* SystemPermissions.swift in Sources */, D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */, - D04BA0F3D46BC79544E2B930 /* Extensions.swift in Sources */, D04BAD4DE538FDF7E7532EE2 /* Labels.swift in Sources */, F029861A378EC1417106FEC3 /* TextField.swift in Sources */, - F0298AB28A3CE5DBEC385730 /* HyperlinkLabel.swift in Sources */, - D04BACD2FFB12589F9286B47 /* AccessibilityApis.swift in Sources */, - D04BA89A77CFDC4A3DF30487 /* CoreGraphicsApis.swift in Sources */, + F0298AB28A3CE5DBEC385730 /* HyperlinkLabel.swift in Sources */, + D04BA308162F8043F8561D03 /* AXUIElement.swift in Sources */, + D04BA0496ACF1427B6E9D369 /* CGWindow.swift in Sources */, + D04BAE369A14C3126A1606FE /* HelperExtensions.swift in Sources */, + D04BAE2E8E9B9898A4DF9B3B /* FontIcon.swift in Sources */, + D04BA4D356055A39B97712DE /* PrivateApis.swift in Sources */, + D04BA6B6B703DCEFE892D5A4 /* Spaces.swift in Sources */, + D04BA2CBF0EFA04CC80EC1BC /* TrackedWindow.swift in Sources */, + D04BA57FB9EF1373D59A1AA7 /* CGWindowID.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -330,6 +382,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "alt-tab-macos/alt_tab_macos.entitlements"; COMBINE_HIDPI_IMAGES = YES; + FRAMEWORK_SEARCH_PATHS = /System/Library/PrivateFrameworks; INFOPLIST_FILE = "alt-tab-macos/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 10.12; @@ -345,6 +398,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "alt-tab-macos/alt_tab_macos.entitlements"; COMBINE_HIDPI_IMAGES = YES; + FRAMEWORK_SEARCH_PATHS = /System/Library/PrivateFrameworks; INFOPLIST_FILE = "alt-tab-macos/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 10.12; diff --git a/alt-tab-macos/Info.plist b/alt-tab-macos/Info.plist index f07df163..dbdf52ee 100644 --- a/alt-tab-macos/Info.plist +++ b/alt-tab-macos/Info.plist @@ -28,5 +28,7 @@ NSApplication LSUIElement 1 + ATSApplicationFontsPath + diff --git a/alt-tab-macos/api-wrappers/AXUIElement.swift b/alt-tab-macos/api-wrappers/AXUIElement.swift new file mode 100644 index 00000000..ba65ac22 --- /dev/null +++ b/alt-tab-macos/api-wrappers/AXUIElement.swift @@ -0,0 +1,83 @@ +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" +} + +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 { + var id = CGWindowID(0) + _AXUIElementGetWindow(self, &id) + return id + } + + func focusedWindow() -> AXUIElement? { + return attribute(.focusedWindow, AXUIElement.self) + } + + func windows() -> [AXUIElement]? { + return attribute(.windows, [AXUIElement].self) + } + + func isMinimized() -> Bool { + return attribute(.minimized, Bool.self) == true + } + + func focus(_ id: CGWindowID) { + var elementConnection = UInt32(0) + CGSGetWindowOwner(cgsMainConnectionId, id, &elementConnection) + var psn = ProcessSerialNumber() + CGSGetConnectionPSN(elementConnection, &psn) + _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 new file mode 100644 index 00000000..964f166b --- /dev/null +++ b/alt-tab-macos/api-wrappers/CGWindow.swift @@ -0,0 +1,36 @@ +import Cocoa +import Foundation + +typealias CGWindow = [CGWindowKey.RawValue: 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 + } + + // workaround: some apps like chrome use a window to implement the search popover + func isReasonablyBig() -> Bool { + let windowBounds = CGRect(dictionaryRepresentation: value(.bounds, CFDictionary.self)!)! + 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" +} diff --git a/alt-tab-macos/api-wrappers/CGWindowID.swift b/alt-tab-macos/api-wrappers/CGWindowID.swift new file mode 100644 index 00000000..066ec8c6 --- /dev/null +++ b/alt-tab-macos/api-wrappers/CGWindowID.swift @@ -0,0 +1,32 @@ +import Cocoa +import Foundation + +extension CGWindowID { + func AXUIElement(_ ownerPid: pid_t) -> AXUIElement? { + return AXUIElementCreateApplication(ownerPid).windows()?.first(where: { return $0.cgId() == self }) + } + + func AXUIElementOfOtherSpaceWindow(_ ownerPid: pid_t) -> AXUIElement? { + CGSAddWindowsToSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId]) + let axWindow = AXUIElement(ownerPid) + CGSRemoveWindowsFromSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId]) + return axWindow + } + + func screenshot() -> CGImage? { + // CGSHWCaptureWindowList + var windowId_ = self + let options: CGSWindowCaptureOptions = [.captureIgnoreGlobalClipShape, .windowCaptureNominalResolution] + let list = CGSHWCaptureWindowList(cgsMainConnectionId, &windowId_, 1, options) as! [CGImage] + return list.first + +// // CGWindowListCreateImage +// return CGWindowListCreateImage(.null, .optionIncludingWindow, self, [.boundsIgnoreFraming, .bestResolution]) + +// // CGSCaptureWindowsContentsToRectWithOptions +// var windowId_ = self +// var windowImage = CIContext(options: nil).createCGImage(CIImage.empty(), from: CIImage.empty().extent)! +// CGSCaptureWindowsContentsToRectWithOptions(cgsMainConnectionId, &windowId_, true, .zero, [.windowCaptureNominalResolution, .captureIgnoreGlobalClipShape], &windowImage) +// return windowImage + } +} diff --git a/alt-tab-macos/logic/Extensions.swift b/alt-tab-macos/api-wrappers/HelperExtensions.swift similarity index 78% rename from alt-tab-macos/logic/Extensions.swift rename to alt-tab-macos/api-wrappers/HelperExtensions.swift index 3a2b5b29..7fbe44c1 100644 --- a/alt-tab-macos/logic/Extensions.swift +++ b/alt-tab-macos/api-wrappers/HelperExtensions.swift @@ -1,8 +1,8 @@ import Foundation import Cocoa -// add CGFloat constructor from String extension CGFloat { + // add CGFloat constructor from String init?(_ string: String) { guard let number = NumberFormatter().number(from: string) else { return nil @@ -11,8 +11,8 @@ extension CGFloat { } } -// add throw-on-nil method on Optional extension Optional { + // add throw-on-nil method on Optional func orThrow() throws -> Wrapped { switch self { case .some(let value): @@ -24,8 +24,8 @@ extension Optional { } } -// add String constructor from CGFloat that round up at 1 decimal extension String { + // add String constructor from CGFloat that round up at 1 decimal init?(_ cgFloat: CGFloat) { let formatter = NumberFormatter() formatter.maximumFractionDigits = 1 @@ -36,8 +36,8 @@ extension String { } } -// add recursive lookup in subviews for specific type extension NSView { + // add recursive lookup in subviews for specific type func findNestedViews(subclassOf: T.Type) -> [T] { return recursiveSubviews.compactMap { $0 as? T } } @@ -47,8 +47,8 @@ extension NSView { } } -// add convenience to NSError extension NSError { + // add convenience to NSError class func make(domain: String, message: String, code: Int = 9999) -> NSError { return NSError( domain: domain, @@ -57,3 +57,10 @@ extension NSError { ) } } + +extension Collection { + // recursive flatMap + func joined() -> [Any] { + return flatMap { ($0 as? [Any])?.joined() ?? [$0] } + } +} diff --git a/alt-tab-macos/api-wrappers/PrivateApis.swift b/alt-tab-macos/api-wrappers/PrivateApis.swift new file mode 100644 index 00000000..2a5b7afd --- /dev/null +++ b/alt-tab-macos/api-wrappers/PrivateApis.swift @@ -0,0 +1,286 @@ +import Cocoa +import Foundation + +// Private APIs are APIs that we can build the app against, but they are not supported or documented by Apple +// We can see their names as symbols in the SDK (see https://github.com/lwouis/MacOSX-SDKs) +// However their full signature is a best-effort of retro-engineering +// Very little information is available about private APIs. I tried to document them as much as possible here +// Some links: +// * Webkit repo: https://github.com/WebKit/webkit/blob/master/Source/WebCore/PAL/pal/spi/cg/CoreGraphicsSPI.h +// * Alt-tab-macos issue: https://github.com/lwouis/alt-tab-macos/pull/87#issuecomment-558624755 + +typealias CGSConnectionID = UInt32 +typealias CGSSpaceID = UInt64 + +struct CGSWindowCaptureOptions: OptionSet { + let rawValue: UInt32 + static let windowCaptureNominalResolution = CGSWindowCaptureOptions(rawValue: 1 << 0x0200) + static let captureIgnoreGlobalClipShape = CGSWindowCaptureOptions(rawValue: 1 << 0x0800) +} + +enum SLPSMode: UInt32 { + case allWindows = 0x100 + case userGenerated = 0x200 + case noWindows = 0x400 +} + +// returns the connection to the WindowServer. This connection ID is required when calling other APIs +// * macOS 10.10+ +@_silgen_name("CGSMainConnectionID") +func CGSMainConnectionID() -> CGSConnectionID + +// returns an array of CGImage of the windows which ID is given as `windowList`. `windowList` is supposed to be an array of IDs but in my test on High Sierra, the function ignores other IDs than the first, and always returns the screenshot of the first window in the array +// * performance: the `HW` in the name seems to imply better performance, and it was observed by some contributors that it seems to be faster (see https://github.com/lwouis/alt-tab-macos/issues/45) than other methods +// * quality: medium +// * minimized windows: yes +// * windows in other spaces: yes +// * macOS 10.10+ +@_silgen_name("CGSHWCaptureWindowList") +func CGSHWCaptureWindowList(_ cid: CGSConnectionID, _ windowList: inout CGWindowID, _ windowCount: UInt32, _ options: CGSWindowCaptureOptions) -> CFArray + +// returns the CGImage of the window which ID is given in `wid` +// * performance: it seems that this function performs similarly to public API `CGWindowListCreateImage` +// * quality: low +// * minimized windows: yes +// * windows in other spaces: yes +// * macOS 10.10+ +@_silgen_name("CGSCaptureWindowsContentsToRectWithOptions") @discardableResult +func CGSCaptureWindowsContentsToRectWithOptions(_ cid: CGSConnectionID, _ wid: inout CGWindowID, _ windowOnly: Bool, _ rect: CGRect, _ options: CGSWindowCaptureOptions, _ image: inout CGImage) -> CGError + +// returns the connection ID for the provided window +// * macOS 10.10+ +@_silgen_name("CGSGetWindowOwner") @discardableResult +func CGSGetWindowOwner(_ cid: CGSConnectionID, _ wid: CGWindowID, _ windowCid: inout CGSConnectionID) -> CGError + +// returns the PSN for the provided connection ID +// * macOS 10.10+ +@_silgen_name("CGSGetConnectionPSN") @discardableResult +func CGSGetConnectionPSN(_ cid: CGSConnectionID, _ psn: inout ProcessSerialNumber) -> CGError + +// returns an array of displays (as NSDictionary) -> each having an array of spaces (as NSDictionary) at the "Spaces" key; each having a space ID (as UInt64) at the "id64" key +// /!\ only returns correct values if the user has checked the checkbox in Preferences > Mission Control > "Displays have separate Spaces" +// * macOS 10.10+ +@_silgen_name("CGSCopyManagedDisplaySpaces") +func CGSCopyManagedDisplaySpaces(_ cid: CGSConnectionID) -> CFArray + +// returns an array of window IDs (as UInt32) for the space(s) provided as `spaces` +// the elements of the array are ordered by the z-index order of the windows in each space, with some exceptions where spaces mix +// * macOS 10.10+ +@_silgen_name("CGSCopyWindowsWithOptionsAndTags") +func CGSCopyWindowsWithOptionsAndTags(_ cid: CGSConnectionID, _ owner: UInt32, _ spaces: CFArray, _ options: UInt32, _ setTags: inout UInt64, _ clearTags: inout UInt64) -> CFArray + +// returns the current space ID on the provided display UUID +// * macOS 10.10+ +@_silgen_name("CGSManagedDisplayGetCurrentSpace") +func CGSManagedDisplayGetCurrentSpace(_ cid: CGSConnectionID, _ displayUuid: CFString) -> CGSSpaceID + +// adds the provided windows to the provided spaces +// * macOS 10.10+ +@_silgen_name("CGSAddWindowsToSpaces") +func CGSAddWindowsToSpaces(_ cid: CGSConnectionID, _ windows: NSArray, _ spaces: NSArray) -> Void + +// remove the provided windows from the provided spaces +// * macOS 10.10+ +@_silgen_name("CGSRemoveWindowsFromSpaces") +func CGSRemoveWindowsFromSpaces(_ cid: CGSConnectionID, _ windows: NSArray, _ spaces: NSArray) -> Void + +// returns the front process PSN +// * macOS 10.12+ +@_silgen_name("_SLPSGetFrontProcess") @discardableResult +func _SLPSGetFrontProcess(_ psn: inout ProcessSerialNumber) -> OSStatus + +// focuses the front process +// * macOS 10.12+ +@_silgen_name("_SLPSSetFrontProcessWithOptions") @discardableResult +func _SLPSSetFrontProcessWithOptions(_ psn: inout ProcessSerialNumber, _ wid: CGWindowID, _ mode: SLPSMode) -> CGError + +// sends bytes to the WindowServer +// more context: https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468 +// * macOS 10.12+ +@_silgen_name("SLPSPostEventRecordTo") @discardableResult +func SLPSPostEventRecordTo(_ psn: inout ProcessSerialNumber, _ bytes: inout UInt8) -> CGError + +// returns the CGWindowID of the provided AXUIElement +// * macOS 10.10+ +@_silgen_name("_AXUIElementGetWindow") @discardableResult +func _AXUIElementGetWindow(_ axUiElement: AXUIElement, _ wid: inout CGWindowID) -> AXError + + + + +// ------------------------------------------------------------ +// below are some notes on some private APIs I experimented with +// ------------------------------------------------------------ + +//// returns true is the PSNs are the same +//// * deprecated in macOS 10.9, so we have to declare it to use it in Swift +//@_silgen_name("SameProcess") +//func SameProcess(_ psn1: inout ProcessSerialNumber, _ psn2: inout ProcessSerialNumber, _ same: inout DarwinBoolean) -> Void +// +//// returns the CGRect of a window +//// * performance: it seems that this function is faster than the public API AX calls to get a window bounds +//// * minimized windows: ? +//// * windows in other spaces: ? +//// * macOS 10.12+ +//@_silgen_name("CGSGetWindowBounds") @discardableResult +//func CGSGetWindowBounds(_ cid: CGSConnectionID, _ wid: inout CGWindowID, _ frame: inout CGRect) -> CGError +// +//// * deprecated in macOS 10.9, so we have to declare it to use it in Swift +//@_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 +//func CGSGetWindowType(_ wid: CGWindowID, _ type: inout UInt32) -> CGError +// +//// seems to always return 0, for windows in the same space, other space, minimized, etc +//// * macOS 10.10+ +//@_silgen_name("CGSGetWindowLevel") @discardableResult +//func CGSGetWindowLevel(_ cid: CGSConnectionID, _ wid: CGWindowID, _ level: inout UInt32) -> CGError +// +//// * macOS 10.12+ +//@_silgen_name("CGSProcessAssignToSpace") @discardableResult +//func CGSProcessAssignToSpace(_ cid: CGSConnectionID, _ pid: pid_t, _ sid: CGSSpaceID) -> CGError +// +//// changes the active space for the display_ref (e.g. "Main"). This doesn't actually trigger the UI animation and switch to the space. It allows windows from that space to be manipulated (e.g. focused) from the current space. Very weird behaviour and graphical glitch will happen when triggering Mission Control +//// * macOS 10.10+ +//@_silgen_name("CGSManagedDisplaySetCurrentSpace") +//func CGSManagedDisplaySetCurrentSpace(_ cid: CGSConnectionID, _ display: CFString, _ sid: CGSSpaceID) -> Void +// +//// get space for window +//// * macOS 10.10+ +//@_silgen_name("CGSGetWindowWorkspace") @discardableResult +//func CGSGetWindowWorkspace(_ cid: CGSConnectionID, _ wid: CGWindowID, _ workspace: [Int]) -> OSStatus +// +//// returns the space uuid. Not very useful +//// * macOS 10.10+ +//@_silgen_name("CGSSpaceCopyName") +//func CGSSpaceCopyName(_ cid: CGSConnectionID, _ sid: CGSSpaceID) -> CFString +// +//enum CGSWindowOrderingMode: Int { +// case orderAbove = 1 // Window is ordered above target. +// case orderBelow = -1 // Window is ordered below target. +// case orderOut = 0 // Window is removed from the on-screen window list. +//} +// +//// change window order. I tried with relativeToWindow=0, and place=.orderAbove, and it does nothing +//// * macOS 10.10+ +//@_silgen_name("CGSOrderWindow") @discardableResult +//func CGSOrderWindow(_ cid: CGSConnectionID, _ win: CGWindowID, _ place: CGSWindowOrderingMode, relativeTo: CGWindowID /* can be NULL */) -> OSStatus +// +//// Get on-screen window counts and lists. With targetCID=1 -> returns []. With targetCID=0 -> crashes, with targetCID=cid -> crashes +//// * macOS 10.10+ +//@_silgen_name("CGSGetWindowList") @discardableResult +//func CGSGetWindowList(_ cid: CGSConnectionID, _ targetCID: CGSConnectionID, _ count: Int, _ list: [Int], _ outCount: [Int]) -> OSStatus +// +//// per-workspace window counts and lists. Can't compile on macOS 10.14 ("Undefined symbol: _CGSGetWorkspaceWindowList"). There are references of this API on the internet, but it doesn't seem to appear in any SDK though +//// * macOS 10.10+ +//@_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 +// case system +// case unknown +//} +// +//// get the CGSSpaceType for a given space. Maybe useful for fullscreen windows +//// * macOS 10.10+ +//@_silgen_name("CGSSpaceGetType") +//func CGSSpaceGetType(_ connection: CGSConnectionID, _ space: CGSSpaceID) -> CGSSpaceType +// +//// assigns a process to all spaces. This creates weird behaviours where its windows are available from all spaces +//// * macOS 10.10+ +//@_silgen_name("CGSProcessAssignToAllSpaces") @discardableResult +//func CGSProcessAssignToAllSpaces(_ cid: CGSConnectionID, _ pid: pid_t) -> CGError +// +//enum SpaceManagementMode: Int { +// case separate = 1 +// case notSeparate = 0 +//} +// +//// returns the status of the "Displays have separate Spaces" system Preference +//// * macOS 10.10+ +//@_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 +// var psn_ = psn +// +// var bytes = [UInt8](repeating: 0, count: 0xf8) +// bytes[0x04] = 0xf8 +// bytes[0x08] = 0x0d +// bytes[0x8a] = 0x09 +// +// memcpy(&bytes[0x3c], &wid_, MemoryLayout.size) +// SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes)).pointee)) +//} +// +//func windowManagerDeactivateWindow(_ psn: ProcessSerialNumber, _ wid: CGWindowID) -> Void { +// var wid_ = wid +// var psn_ = psn +// +// var bytes = [UInt8](repeating: 0, count: 0xf8) +// bytes[0x04] = 0xf8 +// bytes[0x08] = 0x0d +// bytes[0x8a] = 0x02 +// +// memcpy(&bytes[0x3c], &wid_, MemoryLayout.size) +// SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes)).pointee)) +//} +// +//func windowManagerActivateWindow(_ psn: ProcessSerialNumber, _ wid: CGWindowID) -> Void { +// var wid_ = wid +// var psn_ = psn +// +// var bytes = [UInt8](repeating: 0, count: 0xf8) +// bytes[0x04] = 0xf8 +// bytes[0x08] = 0x0d +// bytes[0x8a] = 0x01 +// +// memcpy(&bytes[0x3c], &wid_, MemoryLayout.size) +// SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes)).pointee)) +//} +// +//func psnEqual(_ psn1: ProcessSerialNumber, _ psn2: ProcessSerialNumber) -> Bool { +// var psn1_ = psn1 +// var psn2_ = psn2 +// +// var same = DarwinBoolean(false) +// SameProcess(&psn1_, &psn2_, &same) +// return same == DarwinBoolean(true) +//} +// +//func windowIdToPsn(_ wid: CGWindowID) -> ProcessSerialNumber { +// var elementConnection = UInt32(0) +// CGSGetWindowOwner(cgsMainConnectionId, wid, &elementConnection) +// var psn = ProcessSerialNumber() +// CGSGetConnectionPSN(elementConnection, &psn) +// return psn +//} diff --git a/alt-tab-macos/logic/AccessibilityApis.swift b/alt-tab-macos/logic/AccessibilityApis.swift deleted file mode 100644 index 3aa4b02f..00000000 --- a/alt-tab-macos/logic/AccessibilityApis.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Cocoa -import Foundation - -class AccessibilityApis { - static func windows(_ cgOwnerPid: pid_t) -> [AXUIElement] { - if let windows = attribute(AXUIElementCreateApplication(cgOwnerPid), kAXWindowsAttribute, [AXUIElement].self) { - return windows.filter { - // workaround: some apps like chrome use a window to implement the search popover - let windowBounds = value($0, kAXSizeAttribute, NSSize(), .cgSize)! - let isReasonablyBig = windowBounds.width > Preferences.minimumWindowSize && windowBounds.height > Preferences.minimumWindowSize - return isReasonablyBig - } - } - return [] - } - - static func rect(_ element: AXUIElement) -> CGRect { - let sizeBefore = value(element, kAXSizeAttribute, NSSize(), .cgSize)! - let positionBefore = value(element, kAXPositionAttribute, NSPoint(), .cgPoint)! - return CGRect(x: positionBefore.x, y: positionBefore.y, width: sizeBefore.width, height: sizeBefore.height) - } - - static func focus(_ element: AXUIElement) { - AXUIElementPerformAction(element, kAXRaiseAction as CFString) - } - - static func setAttribute(_ element: AXUIElement, _ value: T, _ attribute: String, _ type: AXValueType) { - var v = value - AXUIElementSetAttributeValue(element, attribute as CFString, AXValueCreate(type, &v)!) - } - - private static func value(_ element: AXUIElement, _ key: String, _ target: T, _ type: AXValueType) -> T? { - if let a = attribute(element, key, AXValue.self) { - var value = target - AXValueGetValue(a, type, &value) - return value - } - return nil - } - - private static func attribute(_ element: AXUIElement, _ key: String, _ type: T.Type) -> T? { - var value: AnyObject? - let result = AXUIElementCopyAttributeValue(element, key as CFString, &value) - if result == .success, let typedValue = value as? T { - return typedValue - } - return nil - } -} diff --git a/alt-tab-macos/logic/CoreGraphicsApis.swift b/alt-tab-macos/logic/CoreGraphicsApis.swift deleted file mode 100644 index 7d4eb5fb..00000000 --- a/alt-tab-macos/logic/CoreGraphicsApis.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Cocoa -import Foundation - -class CoreGraphicsApis { - static func windows() -> [NSDictionary] { - return (CGWindowListCopyWindowInfo([.excludeDesktopElements, .optionOnScreenOnly], kCGNullWindowID) as! [NSDictionary]) - .filter { - // workaround: filtering this criteria seems to remove non-windows UI elements - let isWindowNotMenubarOrOthers = value($0, kCGWindowLayer, Int(0)) == 0 - let windowBounds = CGRect(dictionaryRepresentation: value($0, kCGWindowBounds, [:] as CFDictionary))! - // workaround: some apps like chrome use a window to implement the search popover - let isReasonablyBig = windowBounds.width > Preferences.minimumWindowSize && windowBounds.height > Preferences.minimumWindowSize - return isWindowNotMenubarOrOthers && isReasonablyBig - } - } - - static func value(_ cgWindow: NSDictionary, _ key: CFString, _ fallback: T) -> T { - return cgWindow[key] as? T ?? fallback - } - - static func image(_ windowNumber: CGWindowID) -> CGImage? { - return CGWindowListCreateImage(.null, .optionIncludingWindow, windowNumber, [.boundsIgnoreFraming, .bestResolution]) - } -} diff --git a/alt-tab-macos/logic/Keyboard.swift b/alt-tab-macos/logic/Keyboard.swift index fff39efa..eef14de4 100644 --- a/alt-tab-macos/logic/Keyboard.swift +++ b/alt-tab-macos/logic/Keyboard.swift @@ -2,6 +2,8 @@ import Cocoa import Carbon.HIToolbox.Events class Keyboard { + static let backgroundQueue = DispatchQueue(label: "uiQueue", qos: .userInteractive, autoreleaseFrequency: .never) + static func listenToGlobalEvents(_ delegate: Application) { listenToGlobalKeyboardEvents(delegate) } @@ -10,17 +12,16 @@ class Keyboard { var eventTap: CFMachPort? func listenToGlobalKeyboardEvents(_ delegate: Application) { - DispatchQueue.global(qos: .userInteractive).async { + Keyboard.backgroundQueue.async { + Thread.current.name = "uiQueue-thread" 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( tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: eventMask, - callback: { (_, _, event, delegate_) -> Unmanaged? in - let d = Unmanaged.fromOpaque(delegate_!).takeUnretainedValue() - return keyboardHandler(event, d) - }, + callback: keyboardHandler, userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(delegate).toOpaque())) let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) @@ -29,42 +30,43 @@ func listenToGlobalKeyboardEvents(_ delegate: Application) { } } -func keyboardHandler(_ cgEvent: CGEvent, _ delegate: Application) -> Unmanaged? { - if cgEvent.type == .keyDown || cgEvent.type == .keyUp || cgEvent.type == .flagsChanged { - if let event = NSEvent(cgEvent: cgEvent) { - let keyDown = event.type == .keyDown +func dispatchWork(_ application: Application, _ uiWorkShouldBeDone: Bool, _ fn: @escaping () -> Void) -> Unmanaged? { + application.uiWorkShouldBeDone = uiWorkShouldBeDone + DispatchQueue.main.async { + 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() + if type == .keyDown || type == .keyUp || type == .flagsChanged { + if let event = NSEvent(cgEvent: event_) { let isTab = event.keyCode == Preferences.tabKeyCode - let isMeta = Preferences.metaKeyCodes!.contains(event.keyCode) + let isMetaChanged = Preferences.metaKeyCodes!.contains(event.keyCode) + let isMetaDown = event.modifierFlags.contains(Preferences.metaModifierFlag!) let isRightArrow = event.keyCode == kVK_RightArrow let isLeftArrow = event.keyCode == kVK_LeftArrow let isEscape = event.keyCode == kVK_Escape - if event.modifierFlags.contains(Preferences.metaModifierFlag!) { - if keyDown { - if isTab && event.modifierFlags.contains(.shift) { - delegate.showUiOrSelectPrevious() - return nil // previously focused app should not receive keys - } else if isTab { - delegate.showUiOrSelectNext() - return nil // previously focused app should not receive keys - } else if isRightArrow && delegate.appIsBeingUsed { - delegate.cycleSelection(1) - return nil // previously focused app should not receive keys - } else if isLeftArrow && delegate.appIsBeingUsed { - delegate.cycleSelection(-1) - return nil // previously focused app should not receive keys - } else if keyDown && isEscape { - delegate.hideUi() - return nil // previously focused app should not receive keys - } + if isMetaDown && type == .keyDown { + if isTab && event.modifierFlags.contains(.shift) { + return dispatchWork(application, true, { application.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) }) + } else if type == .keyDown && isEscape { + return dispatchWork(application, false, { application.hideUi() }) } - } else if isMeta && !keyDown { - delegate.focusTarget() - return nil // previously focused app should not receive keys + } else if isMetaChanged && !isMetaDown { + return dispatchWork(application, false, { application.focusTarget() }) } } - } else if cgEvent.type == .tapDisabledByUserInput || cgEvent.type == .tapDisabledByTimeout { + } else if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { CGEvent.tapEnable(tap: eventTap!, enable: true) } // focused app will receive the event - return Unmanaged.passRetained(cgEvent) + return Unmanaged.passRetained(event_) } diff --git a/alt-tab-macos/logic/Preferences.swift b/alt-tab-macos/logic/Preferences.swift index 7fd7b87a..c14ec7a8 100644 --- a/alt-tab-macos/logic/Preferences.swift +++ b/alt-tab-macos/logic/Preferences.swift @@ -2,11 +2,6 @@ import Foundation import Cocoa import Carbon.HIToolbox.Events -enum ShowOnScreenPreference { - case MAIN - case MOUSE -} - class Preferences { static var defaults: [String: String] = [ "maxScreenUsage": "80", @@ -20,12 +15,15 @@ class Preferences { "showOnScreen": showOnScreenMacro.macros[0].label ] static var rawValues = [String: String]() - static var minimumWindowSize: CGFloat = 200 - static var fontColor: NSColor = .white - static var windowMaterial: NSVisualEffectView.Material = .dark - static var windowPadding: CGFloat = 23 - static var interItemPadding: CGFloat = 4 - static var cellPadding: CGFloat = 6 + static var minimumWindowSize = CGFloat(200) + static var emptyThumbnailWidth = CGFloat(200) + static var emptyThumbnailHeight = CGFloat(emptyThumbnailWidth * 9 / 16) + static var fontColor = NSColor.white + static var windowMaterial = NSVisualEffectView.Material.dark + static var windowPadding = CGFloat(23) + static var interItemPadding = CGFloat(4) + static var fontIconSize = CGFloat(20) + static var cellPadding = CGFloat(6) static var cellBorderWidth: CGFloat? static var cellCornerRadius: CGFloat? static var maxScreenUsage: CGFloat? @@ -174,3 +172,8 @@ class MacroPreferenceHelper { } } } + +enum ShowOnScreenPreference { + case MAIN + case MOUSE +} diff --git a/alt-tab-macos/logic/Screen.swift b/alt-tab-macos/logic/Screen.swift index f248b863..41c85732 100644 --- a/alt-tab-macos/logic/Screen.swift +++ b/alt-tab-macos/logic/Screen.swift @@ -2,16 +2,16 @@ import Foundation import Cocoa class Screen { - static func preferredScreen() -> NSScreen { + static func preferred() -> NSScreen { switch Preferences.showOnScreen! { case .MOUSE: - return screenWithMouse() ?? NSScreen.main!; // .main as fall-back + return withMouse() ?? NSScreen.main!; // .main as fall-back case .MAIN: return NSScreen.main!; } } - private static func screenWithMouse() -> NSScreen? { + private static func withMouse() -> NSScreen? { return NSScreen.screens.first { NSMouseInRect(NSEvent.mouseLocation, $0.frame, false) } } @@ -36,6 +36,14 @@ class Screen { panel.makeKeyAndOrderFront(nil) Application.shared.arrangeInFront(nil) } + + static func mainUuid() -> CFString { + return "Main" as CFString + // the bellow code gets the actual main screen, but in our case we seem to be fine with sending "Main" + // our only need for this is for the System Preferences panel which has incorrect space with or without this + //let mainScreenId = NSScreen.main!.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! UInt32 + //return CFUUIDCreateString(nil, CGDisplayCreateUUIDFromDisplayID(mainScreenId).takeRetainedValue())! + } } enum VerticalAlignment: CGFloat { diff --git a/alt-tab-macos/logic/Spaces.swift b/alt-tab-macos/logic/Spaces.swift new file mode 100644 index 00000000..9a144f61 --- /dev/null +++ b/alt-tab-macos/logic/Spaces.swift @@ -0,0 +1,22 @@ +import Cocoa +import Foundation + +class Spaces { + static var singleSpace = true + static var currentSpaceId = CGSSpaceID(1) + static var currentSpaceIndex = SpaceIndex(1) + + static func allIdsAndIndexes() -> [(CGSSpaceID, SpaceIndex)] { + return (CGSCopyManagedDisplaySpaces(cgsMainConnectionId) as! [NSDictionary]) + .map { return $0["Spaces"] }.joined().enumerated() + .map { (($0.element as! NSDictionary)["id64"]! as! CGSSpaceID, $0.offset + 1) } + } + + static func windowsInSpaces(_ spaceIds: [CGSSpaceID]) -> [CGWindowID] { + var set_tags = UInt64(0) + var clear_tags = UInt64(0) + return CGSCopyWindowsWithOptionsAndTags(cgsMainConnectionId, 0, spaceIds as CFArray, 2, &set_tags, &clear_tags) as! [CGWindowID] + } +} + +typealias SpaceIndex = Int diff --git a/alt-tab-macos/logic/SystemPermissions.swift b/alt-tab-macos/logic/SystemPermissions.swift index 55c8a280..a7b67615 100644 --- a/alt-tab-macos/logic/SystemPermissions.swift +++ b/alt-tab-macos/logic/SystemPermissions.swift @@ -1,7 +1,9 @@ import Foundation import Cocoa +// macOS has some privacy restrictions. The user needs to grant certain permissions, app by app, in System Preferences > Security & Privacy > Privacy class SystemPermissions { + // macOS 10.9+ static func ensureAccessibilityCheckboxIsChecked() { if !AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) { debugPrint("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Accessibility.", @@ -12,10 +14,10 @@ class SystemPermissions { } } + // macOS 10.15+ static func ensureScreenRecordingCheckboxIsChecked() { - let firstWindow = CoreGraphicsApis.windows()[0] - let windowNumber = CoreGraphicsApis.value(firstWindow, kCGWindowNumber, UInt32(0)) - if CoreGraphicsApis.image(windowNumber) == nil { + let firstWindow = CGWindow.windows(.optionOnScreenOnly)[0] + if let cgId = firstWindow.value(.number, CGWindowID.self), 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 new file mode 100644 index 00000000..5e937de6 --- /dev/null +++ b/alt-tab-macos/logic/TrackedWindow.swift @@ -0,0 +1,45 @@ +import Cocoa +import Foundation + +class TrackedWindow { + var cgWindow: CGWindow + var ownerPid: pid_t + var id: CGWindowID + var title: String + var thumbnail: NSImage? + var icon: NSImage? + var app: NSRunningApplication? + var axWindow: AXUIElement? + var isMinimized: Bool + var spaceId: CGSSpaceID? + var spaceIndex: SpaceIndex? + var rank: WindowRank? + + init(_ cgWindow: CGWindow, _ cgId: CGWindowID, _ ownerPid: pid_t, _ isMinimized: Bool, _ axWindow: AXUIElement?, _ spaceId: CGSSpaceID?, _ spaceIndex: SpaceIndex?, _ rank: WindowRank?) { + self.cgWindow = cgWindow + self.id = cgId + self.ownerPid = ownerPid + 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 = NSRunningApplication(processIdentifier: ownerPid) + 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.isMinimized = isMinimized + self.spaceId = spaceId + // System Preferences windows appear on all spaces, so we make them the current space + self.spaceIndex = app?.bundleIdentifier == "com.apple.systempreferences" ? Spaces.currentSpaceIndex : spaceIndex + self.rank = rank + } + + func focus() { + if axWindow == nil { + axWindow = id.AXUIElementOfOtherSpaceWindow(ownerPid) + } + axWindow?.focus(id) + } +} diff --git a/alt-tab-macos/logic/TrackedWindows.swift b/alt-tab-macos/logic/TrackedWindows.swift new file mode 100644 index 00000000..56f2b361 --- /dev/null +++ b/alt-tab-macos/logic/TrackedWindows.swift @@ -0,0 +1,77 @@ +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.singleSpace = spaces.count == 1 + Spaces.currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, Screen.mainUuid()) + Spaces.currentSpaceIndex = spaces.first { $0.0 == Spaces.currentSpaceId }!.1 + filterAndAddToList(mapWindowsWithRankAndSpace(spaces)) + sortList() + TrackedWindows.focusedWindowIndex = TrackedWindows.moveFocusedWindowIndex(step) + } + + private static func mapWindowsWithRankAndSpace(_ spaces: [(CGSSpaceID, SpaceIndex)]) -> [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)] { + 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! [CGWindowID : (CGSSpaceID, SpaceIndex, WindowRank)] + } + + 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: [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)]) { + for cgWindow in CGWindow.windows(.optionAll) { + guard let cgId = cgWindow.value(.number, CGWindowID.self), + let ownerPid = cgWindow.value(.ownerPID, pid_t.self), + cgWindow.isNotMenubarOrOthers(), + cgWindow.isReasonablyBig() else { + continue + } + let (spaceId, spaceIndex, rank) = windowsMap[cgId] ?? (nil, nil, nil) + if let axWindow = cgId.AXUIElement(ownerPid) { + if spaceId != nil { + list.append(TrackedWindow(cgWindow, cgId, ownerPid, false, axWindow, spaceId, spaceIndex, rank)) + } else if axWindow.isMinimized() { + list.append(TrackedWindow(cgWindow, cgId, ownerPid, true, axWindow, spaceId, spaceIndex, rank)) + } + } else { + // window is on another space + guard spaceId != nil else { continue } + list.append(TrackedWindow(cgWindow, cgId, ownerPid, false, nil, spaceId, spaceIndex, rank)) + } + } + } +} + +typealias WindowRank = Int diff --git a/alt-tab-macos/logic/WindowManager.swift b/alt-tab-macos/logic/WindowManager.swift deleted file mode 100644 index 0ab6897a..00000000 --- a/alt-tab-macos/logic/WindowManager.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Cocoa -import Foundation - -class OpenWindow { - var target: AXUIElement? - var ownerPid: pid_t? - var cgId: CGWindowID - var cgTitle: String - lazy var thumbnail: NSImage = computeThumbnail() - lazy var icon: NSImage? = computeIcon() - - init(_ target: AXUIElement?, _ ownerPid: pid_t?, _ cgId: CGWindowID, _ cgTitle: String) { - self.target = target - self.ownerPid = ownerPid - self.cgId = cgId - self.cgTitle = cgTitle - } - - func computeIcon() -> NSImage? { - return NSRunningApplication(processIdentifier: ownerPid!)?.icon - } - - func computeThumbnail() -> NSImage { - let windowImage = CoreGraphicsApis.image(cgId) - return NSImage(cgImage: windowImage!, size: NSSize(width: windowImage!.width, height: windowImage!.height)) - } - - func focus() { - if let app = NSRunningApplication(processIdentifier: ownerPid!) { - app.activate(options: [.activateIgnoringOtherApps]) - AccessibilityApis.focus(target!) - } - } -} - -func computeDownscaledSize(_ image: NSImage, _ screen: NSScreen) -> (Int, Int) { - let imageRatio = image.size.width / image.size.height - let thumbnailMaxSize = Screen.thumbnailMaxSize(screen) - let thumbnailWidth = Int(floor(thumbnailMaxSize.height * imageRatio)) - if thumbnailWidth <= Int(thumbnailMaxSize.width) { - return (thumbnailWidth, Int(thumbnailMaxSize.height)) - } else { - return (Int(thumbnailMaxSize.width), Int(floor(thumbnailMaxSize.width / imageRatio))) - } -} diff --git a/alt-tab-macos/resources/SF-Pro-Text-Regular.otf b/alt-tab-macos/resources/SF-Pro-Text-Regular.otf new file mode 100644 index 00000000..3f23f8fa Binary files /dev/null and b/alt-tab-macos/resources/SF-Pro-Text-Regular.otf differ diff --git a/alt-tab-macos/ui/Application.swift b/alt-tab-macos/ui/Application.swift index 6405cf7b..9c96e3f7 100644 --- a/alt-tab-macos/ui/Application.swift +++ b/alt-tab-macos/ui/Application.swift @@ -1,17 +1,16 @@ import Foundation import Cocoa +let cgsMainConnectionId = CGSMainConnectionID() + class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { static let name = "AltTab" var statusItem: NSStatusItem? var thumbnailsPanel: ThumbnailsPanel? var preferencesPanel: PreferencesPanel? - var selectedOpenWindow: Int = 0 - var numberOfColumns: Int = 0 - var openWindows: [OpenWindow] = [] - var workItems: [DispatchWorkItem] = [] - var isFirstSummon: Bool = true - var appIsBeingUsed: Bool = false + var uiWorkShouldBeDone = true + var isFirstSummon = true + var appIsBeingUsed = false override init() { super.init() @@ -29,6 +28,12 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { statusItem = StatusItem.make(self) initPreferencesDependentComponents() 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 @@ -36,19 +41,9 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { thumbnailsPanel = ThumbnailsPanel(self) } - func showUiOrSelectNext() { - debugPrint("showUiOrSelectNext") - showUiOrCycleSelection(1) - } - - func showUiOrSelectPrevious() { - debugPrint("showUiOrSelectPrevious") - showUiOrCycleSelection(-1) - } - func hideUi() { debugPrint("hideUi") - DispatchQueue.main.async(execute: { self.thumbnailsPanel!.orderOut(nil) }) + thumbnailsPanel!.orderOut(nil) appIsBeingUsed = false isFirstSummon = true } @@ -56,8 +51,8 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { func focusTarget() { debugPrint("focusTarget") if appIsBeingUsed { - focusSelectedWindow(currentlySelectedWindow()) - hideUi() + debugPrint("focusTarget: appIsBeingUsed") + focusSelectedWindow(TrackedWindows.focusedWindow()) } } @@ -66,69 +61,36 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { if preferencesPanel == nil { preferencesPanel = PreferencesPanel() } - Screen.showPanel(preferencesPanel!, Screen.preferredScreen(), .appleCentered) - } - - func computeOpenWindows() { - openWindows.removeAll() - // we rely on the fact that CG and AX APIs arrays follow the same order to match objects from both APIs - var pidAndCurrentIndex: [pid_t: Int] = [:] - for cgWindow in CoreGraphicsApis.windows() { - let cgId = CoreGraphicsApis.value(cgWindow, kCGWindowNumber, UInt32(0)) - let cgTitle = CoreGraphicsApis.value(cgWindow, kCGWindowName, "") - let cgOwnerName = CoreGraphicsApis.value(cgWindow, kCGWindowOwnerName, "") - let cgOwnerPid = CoreGraphicsApis.value(cgWindow, kCGWindowOwnerPID, Int32(0)) - let i = pidAndCurrentIndex.index(forKey: cgOwnerPid) - pidAndCurrentIndex[cgOwnerPid] = (i == nil ? 0 : pidAndCurrentIndex[i!].value + 1) - let axWindows_ = AccessibilityApis.windows(cgOwnerPid) - // windows may have changed between the CG and the AX calls - if axWindows_.count > pidAndCurrentIndex[cgOwnerPid]! { - openWindows.append(OpenWindow(axWindows_[pidAndCurrentIndex[cgOwnerPid]!], cgOwnerPid, cgId, cgTitle.isEmpty ? cgOwnerName : cgTitle)) - } - } - } - - func cellWithStep(_ step: Int) -> Int { - return selectedOpenWindow + step < 0 ? openWindows.count - 1 : (selectedOpenWindow + step) % openWindows.count + Screen.showPanel(preferencesPanel!, Screen.preferred(), .appleCentered) } func cycleSelection(_ step: Int) { - selectedOpenWindow = cellWithStep(step) - DispatchQueue.main.async(execute: { self.thumbnailsPanel!.highlightCellAt(step) }) + TrackedWindows.focusedWindowIndex = TrackedWindows.moveFocusedWindowIndex(step) + self.thumbnailsPanel!.highlightCellAt(step) } func showUiOrCycleSelection(_ step: Int) { + debugPrint("showUiOrCycleSelection", step) appIsBeingUsed = true if isFirstSummon { + debugPrint("showUiOrCycleSelection: isFirstSummon") isFirstSummon = false - selectedOpenWindow = 0 - computeOpenWindows() - if openWindows.count <= 0 { + TrackedWindows.refreshList(step) + if TrackedWindows.list.count == 0 { return } - selectedOpenWindow = cellWithStep(step) - var workItem: DispatchWorkItem! - workItem = DispatchWorkItem { - let currentScreen = Screen.preferredScreen() // fix screen between steps since it could change (e.g. mouse moved to another screen) - if !workItem.isCancelled { self.thumbnailsPanel!.computeThumbnails(currentScreen) } - if !workItem.isCancelled { self.thumbnailsPanel!.highlightCellAt(step) } - if !workItem.isCancelled { Screen.showPanel(self.thumbnailsPanel!, currentScreen, .appleCentered) } - } - workItems.append(workItem) - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay!, execute: workItem) + 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") } } else { + debugPrint("showUiOrCycleSelection: !isFirstSummon") cycleSelection(step) } } - func focusSelectedWindow(_ window: OpenWindow?) { - workItems.forEach({ $0.cancel() }) - workItems.removeAll() - window?.focus() - } - - func currentlySelectedWindow() -> OpenWindow? { - return openWindows.count > selectedOpenWindow ? openWindows[selectedOpenWindow] : nil + func focusSelectedWindow(_ window: TrackedWindow?) { + hideUi() + DispatchQueue.global(qos: .userInteractive).async { window?.focus() } } - } diff --git a/alt-tab-macos/ui/Cell.swift b/alt-tab-macos/ui/Cell.swift index 754e656b..a470a212 100644 --- a/alt-tab-macos/ui/Cell.swift +++ b/alt-tab-macos/ui/Cell.swift @@ -1,13 +1,16 @@ import Cocoa +import WebKit -typealias MouseDownCallback = (OpenWindow) -> Void +typealias MouseDownCallback = (TrackedWindow) -> Void typealias MouseMovedCallback = (Cell) -> Void class Cell: NSCollectionViewItem { var thumbnail = NSImageView() - var icon = NSImageView() - var label = CellTitle() - var openWindow: OpenWindow? + var appIcon = NSImageView() + var label = CellTitle(Preferences.fontHeight!) + var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white) + var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white) + var openWindow: TrackedWindow? var mouseDownCallback: MouseDownCallback? var mouseMovedCallback: MouseMovedCallback? @@ -16,7 +19,7 @@ class Cell: NSCollectionViewItem { let vStackView = makeVStackView(hStackView) let shadow = Cell.makeShadow(.gray) thumbnail.shadow = shadow - icon.shadow = shadow + appIcon.shadow = shadow view = vStackView } @@ -35,19 +38,25 @@ class Cell: NSCollectionViewItem { } } - func updateWithNewContent(_ element: OpenWindow, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) { + func updateWithNewContent(_ element: TrackedWindow, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) { openWindow = element thumbnail.image = element.thumbnail - let (width, height) = computeDownscaledSize(element.thumbnail, screen) - thumbnail.image!.size = NSSize(width: width, height: height) + let (width, height) = Cell.computeDownscaledSize(element.thumbnail, screen) + thumbnail.image?.size = NSSize(width: width, height: height) thumbnail.frame.size = NSSize(width: width, height: height) - icon.image = element.icon - icon.image?.size = NSSize(width: Preferences.iconSize!, height: Preferences.iconSize!) - icon.frame.size = NSSize(width: Preferences.iconSize!, height: Preferences.iconSize!) - label.string = element.cgTitle + appIcon.image = element.icon + appIcon.image?.size = NSSize(width: Preferences.iconSize!, height: Preferences.iconSize!) + appIcon.frame.size = NSSize(width: Preferences.iconSize!, height: Preferences.iconSize!) + label.string = element.title // workaround: setting string on NSTextView change the font (most likely a Cocoa bug) label.font = Preferences.font! - label.textContainer!.size.width = thumbnail.frame.size.width - Preferences.iconSize! - Preferences.interItemPadding + let fontIconWidth = Spaces.singleSpace && !openWindow!.isMinimized ? 0 : Preferences.fontIconSize + Preferences.interItemPadding + label.textContainer!.size.width = thumbnail.frame.width - Preferences.iconSize! - Preferences.interItemPadding - fontIconWidth + minimizedIcon.isHidden = !openWindow!.isMinimized + spaceIcon.isHidden = openWindow!.isMinimized || Spaces.singleSpace + if !spaceIcon.isHidden { + spaceIcon.setNumber(UInt32(element.spaceIndex!)) + } self.mouseDownCallback = mouseDownCallback self.mouseMovedCallback = mouseMovedCallback if view.trackingAreas.count > 0 { @@ -56,6 +65,20 @@ class Cell: NSCollectionViewItem { view.addTrackingArea(NSTrackingArea(rect: view.bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil)) } + static func computeDownscaledSize(_ image: NSImage?, _ screen: NSScreen) -> (Int, Int) { + if let image_ = image { + let imageRatio = image_.size.width / image_.size.height + let thumbnailMaxSize = Screen.thumbnailMaxSize(screen) + let thumbnailWidth = Int(floor(thumbnailMaxSize.height * imageRatio)) + if thumbnailWidth <= Int(thumbnailMaxSize.width) { + return (thumbnailWidth, Int(thumbnailMaxSize.height)) + } else { + return (Int(thumbnailMaxSize.width), Int(floor(thumbnailMaxSize.width / imageRatio))) + } + } + return (Int(Preferences.emptyThumbnailWidth), Int((Preferences.emptyThumbnailHeight))) + } + static func makeShadow(_ color: NSColor) -> NSShadow { let shadow = NSShadow() shadow.shadowColor = color @@ -67,8 +90,10 @@ class Cell: NSCollectionViewItem { private func makeHStackView() -> NSStackView { let hStackView = NSStackView() hStackView.spacing = Preferences.interItemPadding - hStackView.addView(icon, in: .leading) + hStackView.addView(appIcon, in: .leading) hStackView.addView(label, in: .leading) + hStackView.addView(minimizedIcon, in: .leading) + hStackView.addView(spaceIcon, in: .leading) return hStackView } diff --git a/alt-tab-macos/ui/FontIcon.swift b/alt-tab-macos/ui/FontIcon.swift new file mode 100644 index 00000000..503105fa --- /dev/null +++ b/alt-tab-macos/ui/FontIcon.swift @@ -0,0 +1,38 @@ +import Cocoa + +// Font icon using SF Symbols from the SF Pro font from Apple +// see https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ +class FontIcon: CellTitle { + static let sfSymbolCircledMinusSign = "􀁎" + static let sfSymbolCircledNumber0 = "􀀸" + static let sfSymbolCircledNumber10 = "􀓵" + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + init(_ text: String, _ size: CGFloat, _ color: NSColor) { + // This helps SF symbols display vertically centered and not clipped at the bottom + super.init(size, 3) + string = text + font = NSFont(name: "SF Pro Text", size: size) + textColor = color + // This helps SF symbols not be clipped on the right + widthAnchor.constraint(equalToConstant: size * 1.15).isActive = true + } + + // number should be in the interval [0-50] + func setNumber(_ number: UInt32) { + let (baseCharacter, offset) = baseCharacterAndOffset(number) + string = String(UnicodeScalar(baseCharacter.unicodeScalars.first!.value + offset)!) + } + + private func baseCharacterAndOffset(_ number: UInt32) -> (String, UInt32) { + if number <= 9 { + // numbers alternate between empty and full circles; we skip the full circles + return (FontIcon.sfSymbolCircledNumber0, number * UInt32(2)) + } else { + return (FontIcon.sfSymbolCircledNumber10, number - 10) + } + } +} diff --git a/alt-tab-macos/ui/Labels.swift b/alt-tab-macos/ui/Labels.swift index 2263d8b5..ff91ce33 100644 --- a/alt-tab-macos/ui/Labels.swift +++ b/alt-tab-macos/ui/Labels.swift @@ -7,8 +7,6 @@ class BaseLabel: NSTextView { init(_ text: String) { super.init(frame: .zero) - _init() - heightAnchor.constraint(greaterThanOrEqualToConstant: Preferences.fontHeight! + Preferences.interItemPadding).isActive = true string = text } @@ -28,11 +26,14 @@ class BaseLabel: NSTextView { } class CellTitle: BaseLabel { + let magicOffset: CGFloat + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - init() { + init(_ size: CGFloat, _ magicOffset: CGFloat = 0) { + self.magicOffset = magicOffset let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() textStorage.addLayoutManager(layoutManager) @@ -43,15 +44,15 @@ class CellTitle: BaseLabel { super.init(frame: .zero, textContainer: textContainer) textColor = Preferences.fontColor shadow = Cell.makeShadow(.darkGray) - defaultParagraphStyle = makeParagraphStyle() - heightAnchor.constraint(equalToConstant: Preferences.fontHeight!).isActive = true + defaultParagraphStyle = makeParagraphStyle(size) + heightAnchor.constraint(equalToConstant: size + magicOffset).isActive = true } - private func makeParagraphStyle() -> NSMutableParagraphStyle { + private func makeParagraphStyle(_ size: CGFloat) -> NSMutableParagraphStyle { let paragraphStyle = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle paragraphStyle.lineBreakMode = .byTruncatingTail - paragraphStyle.maximumLineHeight = Preferences.fontHeight! - paragraphStyle.minimumLineHeight = Preferences.fontHeight! + paragraphStyle.maximumLineHeight = size + magicOffset + paragraphStyle.minimumLineHeight = size + magicOffset paragraphStyle.allowsDefaultTighteningForTruncation = false return paragraphStyle } diff --git a/alt-tab-macos/ui/PreferencesPanel.swift b/alt-tab-macos/ui/PreferencesPanel.swift index c241e374..8330d9c0 100644 --- a/alt-tab-macos/ui/PreferencesPanel.swift +++ b/alt-tab-macos/ui/PreferencesPanel.swift @@ -18,11 +18,6 @@ class PreferencesPanel: NSPanel, NSWindowDelegate { contentView = makeContentView() } - override func close() { - (NSApp as! Application).preferencesPanel = nil - super.close() - } - public func windowShouldClose(_ sender: NSWindow) -> Bool { windowCloseRequested = true challengeNextInvalidEditableTextField() diff --git a/alt-tab-macos/ui/ThumbnailsPanel.swift b/alt-tab-macos/ui/ThumbnailsPanel.swift index f04ca4c6..9cf88b46 100644 --- a/alt-tab-macos/ui/ThumbnailsPanel.swift +++ b/alt-tab-macos/ui/ThumbnailsPanel.swift @@ -24,6 +24,8 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele collectionView_ = makeCollectionView() backgroundView = makeBackgroundView() contentView!.addSubview(backgroundView!) + // highest level possible; this allows the app to go on top of context menus + level = .screenSaver } private func makeBackgroundView() -> NSVisualEffectView { @@ -51,44 +53,41 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele func makeLayout() -> CollectionViewCenterFlowLayout { let layout = CollectionViewCenterFlowLayout() - layout.estimatedItemSize = NSSize(width: 200, height: 200) + layout.estimatedItemSize = NSSize(width: Preferences.emptyThumbnailWidth, height: Preferences.emptyThumbnailHeight) layout.minimumInteritemSpacing = 5 layout.minimumLineSpacing = 5 return layout } func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { -// debugPrint("collectionView: count items", application!.openWindows.count) - return application!.openWindows.count + return TrackedWindows.list.count } func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { -// debugPrint("collectionView: make item", indexPath.item) let item = collectionView.makeItem(withIdentifier: cellId, for: indexPath) as! Cell - item.updateWithNewContent(application!.openWindows[indexPath.item], application!.focusSelectedWindow, application!.thumbnailsPanel!.highlightCell, currentScreen!) + item.updateWithNewContent(TrackedWindows.list[indexPath.item], application!.focusSelectedWindow, application!.thumbnailsPanel!.highlightCell, currentScreen!) return item } func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize { -// debugPrint("collectionView: item size") - if indexPath.item < application!.openWindows.count { - let (width, height) = computeDownscaledSize(application!.openWindows[indexPath.item].thumbnail, currentScreen!) + if indexPath.item < TrackedWindows.list.count { + let (width, height) = Cell.computeDownscaledSize(TrackedWindows.list[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: application!.selectedOpenWindow, section: 0)], scrollPosition: .top) - collectionView_!.deselectItems(at: [IndexPath(item: application!.cellWithStep(-step), section: 0)]) + collectionView_!.selectItems(at: [IndexPath(item: TrackedWindows.focusedWindowIndex, section: 0)], scrollPosition: .top) + collectionView_!.deselectItems(at: [IndexPath(item: TrackedWindows.moveFocusedWindowIndex(-step), section: 0)]) } func highlightCell(_ cell: Cell) { let newIndex = collectionView_.indexPath(for: cell)! - if application!.selectedOpenWindow != newIndex.item { + if TrackedWindows.focusedWindowIndex != newIndex.item { collectionView_!.selectItems(at: [newIndex], scrollPosition: .top) - collectionView_!.deselectItems(at: [IndexPath(item: application!.selectedOpenWindow, section: 0)]) - application!.selectedOpenWindow = newIndex.item + collectionView_!.deselectItems(at: [IndexPath(item: TrackedWindows.focusedWindowIndex, section: 0)]) + TrackedWindows.focusedWindowIndex = newIndex.item } }