diff --git a/Hammerspoon.xcodeproj/project.pbxproj b/Hammerspoon.xcodeproj/project.pbxproj index 572df805e..2f1f58e1e 100644 --- a/Hammerspoon.xcodeproj/project.pbxproj +++ b/Hammerspoon.xcodeproj/project.pbxproj @@ -355,7 +355,7 @@ 4FF7396D27344D740007074E /* libscreenwatcher.dylib in Copy Extension Dylibs */ = {isa = PBXBuildFile; fileRef = 4F6B3F551B73EFC100B2A4DE /* libscreenwatcher.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 4FF7396E27344D740007074E /* libsettings.dylib in Copy Extension Dylibs */ = {isa = PBXBuildFile; fileRef = 4F6B3F6A1B73F0E300B2A4DE /* libsettings.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 4FF7396F27344D740007074E /* libsound.dylib in Copy Extension Dylibs */ = {isa = PBXBuildFile; fileRef = 4F6B3F7B1B73F44500B2A4DE /* libsound.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - 4FF7397027344D740007074E /* libspaceswatcher.dylib in Copy Extension Dylibs */ = {isa = PBXBuildFile; fileRef = 4FD0AB1D1B7484D700A82496 /* libspaceswatcher.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + 4FF7397027344D740007074E /* libspaces_watcher.dylib in Copy Extension Dylibs */ = {isa = PBXBuildFile; fileRef = 4FD0AB1D1B7484D700A82496 /* libspaces_watcher.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 4FF7397127344D740007074E /* libtimer.dylib in Copy Extension Dylibs */ = {isa = PBXBuildFile; fileRef = 4FD0AB341B74867600A82496 /* libtimer.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 4FF7397227344D740007074E /* libuielement.dylib in Copy Extension Dylibs */ = {isa = PBXBuildFile; fileRef = 4FD0AB461B749C8E00A82496 /* libuielement.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 4FF7397327344D740007074E /* liburlevent.dylib in Copy Extension Dylibs */ = {isa = PBXBuildFile; fileRef = 4FD0AB5B1B749D7A00A82496 /* liburlevent.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -595,6 +595,10 @@ D6BACEAA23178ECA00110E1E /* libdoc.m in Sources */ = {isa = PBXBuildFile; fileRef = D6BACEA923178EC900110E1E /* libdoc.m */; }; D6BB4FBA1C1695BF000DC0A2 /* libspeech.m in Sources */ = {isa = PBXBuildFile; fileRef = D6BB4FA81C169539000DC0A2 /* libspeech.m */; }; D6BB4FC81C169605000DC0A2 /* libspeech_listener.m in Sources */ = {isa = PBXBuildFile; fileRef = D6BB4FA91C169539000DC0A2 /* libspeech_listener.m */; }; + D6C32CC927DDD47F001AA22E /* LuaSkin.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4FB852342735B02400462DD0 /* LuaSkin.framework */; }; + D6C32CD227DDD68E001AA22E /* libspaces.m in Sources */ = {isa = PBXBuildFile; fileRef = D6C32CD027DDD68E001AA22E /* libspaces.m */; }; + D6C32CD327DDD68E001AA22E /* private.h in Headers */ = {isa = PBXBuildFile; fileRef = D6C32CD127DDD68E001AA22E /* private.h */; }; + D6C32CD627DDD811001AA22E /* libspaces.dylib in Copy Extension Dylibs */ = {isa = PBXBuildFile; fileRef = D6C32CCF27DDD47F001AA22E /* libspaces.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; D6E164DD1F4C264D00B85D8A /* libfs_xattr.m in Sources */ = {isa = PBXBuildFile; fileRef = D6E164D11F4C25FD00B85D8A /* libfs_xattr.m */; }; D6EE7C7F249B488C0053FB3A /* ExternalReferences.h in Headers */ = {isa = PBXBuildFile; fileRef = D6EE7C7D249B488B0053FB3A /* ExternalReferences.h */; }; D6EE7C80249B488C0053FB3A /* axtextmarker.m in Sources */ = {isa = PBXBuildFile; fileRef = D6EE7C7E249B488B0053FB3A /* axtextmarker.m */; }; @@ -1257,6 +1261,13 @@ remoteGlobalIDString = D6BB4FBB1C1695EC000DC0A2; remoteInfo = speechlistener; }; + D6C32CD427DDD78F001AA22E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9445CA0319083251002568BB /* Project object */; + proxyType = 1; + remoteGlobalIDString = D6C32CC527DDD47F001AA22E; + remoteInfo = spaces; + }; D6FD56911C04230200BAE238 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9445CA0319083251002568BB /* Project object */; @@ -1325,6 +1336,7 @@ dstPath = hs; dstSubfolderSpec = 10; files = ( + D6C32CD627DDD811001AA22E /* libspaces.dylib in Copy Extension Dylibs */, 22C9154E27A1095200E650A2 /* librazer.dylib in Copy Extension Dylibs */, 4FCA05CD27678B410089A5FC /* libmarkdown.dylib in Copy Extension Dylibs */, 4FF06128274AA7E600FB15D6 /* libcamera.dylib in Copy Extension Dylibs */, @@ -1359,7 +1371,7 @@ 4FF7396D27344D740007074E /* libscreenwatcher.dylib in Copy Extension Dylibs */, 4FF7396E27344D740007074E /* libsettings.dylib in Copy Extension Dylibs */, 4FF7396F27344D740007074E /* libsound.dylib in Copy Extension Dylibs */, - 4FF7397027344D740007074E /* libspaceswatcher.dylib in Copy Extension Dylibs */, + 4FF7397027344D740007074E /* libspaces_watcher.dylib in Copy Extension Dylibs */, 4FF7397127344D740007074E /* libtimer.dylib in Copy Extension Dylibs */, 4FF7397227344D740007074E /* libuielement.dylib in Copy Extension Dylibs */, 4FF7397327344D740007074E /* liburlevent.dylib in Copy Extension Dylibs */, @@ -1830,7 +1842,7 @@ 4FCBAFD823B0FC04007BA1D0 /* test_math.lua */ = {isa = PBXFileReference; lastKnownFileType = text; name = test_math.lua; path = extensions/math/test_math.lua; sourceTree = ""; }; 4FCC52CB1E1526A6007F93D0 /* test_brightness.lua */ = {isa = PBXFileReference; lastKnownFileType = text; name = test_brightness.lua; path = extensions/brightness/test_brightness.lua; sourceTree = ""; }; 4FCC52CD1E1527EF007F93D0 /* HSbrightness.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = HSbrightness.m; sourceTree = ""; }; - 4FD0AB1D1B7484D700A82496 /* libspaceswatcher.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libspaceswatcher.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; + 4FD0AB1D1B7484D700A82496 /* libspaces_watcher.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libspaces_watcher.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; 4FD0AB211B74856600A82496 /* spaces.lua */ = {isa = PBXFileReference; lastKnownFileType = text; name = spaces.lua; path = extensions/spaces/spaces.lua; sourceTree = ""; }; 4FD0AB231B74857D00A82496 /* libspaces_watcher.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = libspaces_watcher.m; path = extensions/spaces/libspaces_watcher.m; sourceTree = ""; }; 4FD0AB291B74863000A82496 /* tabs.lua */ = {isa = PBXFileReference; lastKnownFileType = text; name = tabs.lua; path = extensions/tabs/tabs.lua; sourceTree = ""; }; @@ -2081,6 +2093,9 @@ D6BB4FA91C169539000DC0A2 /* libspeech_listener.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = libspeech_listener.m; path = extensions/speech/libspeech_listener.m; sourceTree = ""; }; D6BB4FB91C16959C000DC0A2 /* libspeech.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libspeech.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; D6BB4FC71C1695EC000DC0A2 /* libspeechlistener.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libspeechlistener.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; + D6C32CCF27DDD47F001AA22E /* libspaces.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libspaces.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; + D6C32CD027DDD68E001AA22E /* libspaces.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = libspaces.m; path = extensions/spaces/libspaces.m; sourceTree = ""; }; + D6C32CD127DDD68E001AA22E /* private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = private.h; path = extensions/spaces/private.h; sourceTree = ""; }; D6E071681E04D9FC00C1046A /* drawing_canvasWrapper.lua */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = drawing_canvasWrapper.lua; path = extensions/drawing/drawing_canvasWrapper.lua; sourceTree = ""; }; D6E164D11F4C25FD00B85D8A /* libfs_xattr.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = libfs_xattr.m; path = extensions/fs/libfs_xattr.m; sourceTree = ""; }; D6E164DC1F4C262300B85D8A /* libfsxattr.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libfsxattr.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -2834,6 +2849,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D6C32CC827DDD47F001AA22E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D6C32CC927DDD47F001AA22E /* LuaSkin.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D6E164D61F4C262300B85D8A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -3612,6 +3635,8 @@ 4FD0AB201B74855700A82496 /* spaces */ = { isa = PBXGroup; children = ( + D6C32CD027DDD68E001AA22E /* libspaces.m */, + D6C32CD127DDD68E001AA22E /* private.h */, 4FD0AB211B74856600A82496 /* spaces.lua */, 4FD0AB231B74857D00A82496 /* libspaces_watcher.m */, ); @@ -3967,7 +3992,7 @@ 4F6B3F551B73EFC100B2A4DE /* libscreenwatcher.dylib */, 4F6B3F6A1B73F0E300B2A4DE /* libsettings.dylib */, 4F6B3F7B1B73F44500B2A4DE /* libsound.dylib */, - 4FD0AB1D1B7484D700A82496 /* libspaceswatcher.dylib */, + 4FD0AB1D1B7484D700A82496 /* libspaces_watcher.dylib */, 4FD0AB341B74867600A82496 /* libtimer.dylib */, 4FD0AB461B749C8E00A82496 /* libuielement.dylib */, 4FD0AB5B1B749D7A00A82496 /* liburlevent.dylib */, @@ -4030,6 +4055,7 @@ 4FF06124274AA7A200FB15D6 /* libcamera.dylib */, 4FCA05CA27678B100089A5FC /* libmarkdown.dylib */, 22C9154327A108F000E650A2 /* librazer.dylib */, + D6C32CCF27DDD47F001AA22E /* libspaces.dylib */, ); name = Products; sourceTree = ""; @@ -4991,6 +5017,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D6C32CCA27DDD47F001AA22E /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + D6C32CD327DDD68E001AA22E /* private.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D6E164D81F4C262300B85D8A /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; @@ -5823,9 +5857,9 @@ productReference = 4FCBAFCD23B0D941007BA1D0 /* libmath.dylib */; productType = "com.apple.product-type.library.dynamic"; }; - 4FD0AB141B7484D700A82496 /* spaceswatcher */ = { + 4FD0AB141B7484D700A82496 /* spaces_watcher */ = { isa = PBXNativeTarget; - buildConfigurationList = 4FD0AB1A1B7484D700A82496 /* Build configuration list for PBXNativeTarget "spaceswatcher" */; + buildConfigurationList = 4FD0AB1A1B7484D700A82496 /* Build configuration list for PBXNativeTarget "spaces_watcher" */; buildPhases = ( 4FD0AB151B7484D700A82496 /* Sources */, 4FD0AB161B7484D700A82496 /* Frameworks */, @@ -5835,9 +5869,9 @@ ); dependencies = ( ); - name = spaceswatcher; + name = spaces_watcher; productName = alert; - productReference = 4FD0AB1D1B7484D700A82496 /* libspaceswatcher.dylib */; + productReference = 4FD0AB1D1B7484D700A82496 /* libspaces_watcher.dylib */; productType = "com.apple.product-type.library.dynamic"; }; 4FD0AB2A1B74867600A82496 /* timer */ = { @@ -6220,6 +6254,7 @@ buildRules = ( ); dependencies = ( + D6C32CD527DDD78F001AA22E /* PBXTargetDependency */, 22C9154D27A1094400E650A2 /* PBXTargetDependency */, 4FCA05CC27678B330089A5FC /* PBXTargetDependency */, 4FF06127274AA7D900FB15D6 /* PBXTargetDependency */, @@ -6676,6 +6711,23 @@ productReference = D6BB4FC71C1695EC000DC0A2 /* libspeechlistener.dylib */; productType = "com.apple.product-type.library.dynamic"; }; + D6C32CC527DDD47F001AA22E /* spaces */ = { + isa = PBXNativeTarget; + buildConfigurationList = D6C32CCB27DDD47F001AA22E /* Build configuration list for PBXNativeTarget "spaces" */; + buildPhases = ( + D6C32CC627DDD47F001AA22E /* Sources */, + D6C32CC827DDD47F001AA22E /* Frameworks */, + D6C32CCA27DDD47F001AA22E /* Headers */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = spaces; + productName = alert; + productReference = D6C32CCF27DDD47F001AA22E /* libspaces.dylib */; + productType = "com.apple.product-type.library.dynamic"; + }; D6E164D31F4C262300B85D8A /* fsxattr */ = { isa = PBXNativeTarget; buildConfigurationList = D6E164D91F4C262300B85D8A /* Build configuration list for PBXNativeTarget "fsxattr" */; @@ -6861,7 +6913,8 @@ 4CD203A31C6D76E20089706D /* socket */, 4CD96BD21C73ABD2009FF648 /* socketudp */, 4F6B3F711B73F44500B2A4DE /* sound */, - 4FD0AB141B7484D700A82496 /* spaceswatcher */, + D6C32CC527DDD47F001AA22E /* spaces */, + 4FD0AB141B7484D700A82496 /* spaces_watcher */, D6BB4FAE1C16959C000DC0A2 /* speech */, D6BB4FBB1C1695EC000DC0A2 /* speechlistener */, D6A212EA1DE38F5F00998C0B /* spotlight */, @@ -7868,6 +7921,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D6C32CC627DDD47F001AA22E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D6C32CD227DDD68E001AA22E /* libspaces.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; D6E164D41F4C262300B85D8A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -8140,7 +8201,7 @@ }; 4FD0AB1F1B74852B00A82496 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 4FD0AB141B7484D700A82496 /* spaceswatcher */; + target = 4FD0AB141B7484D700A82496 /* spaces_watcher */; targetProxy = 4FD0AB1E1B74852B00A82496 /* PBXContainerItemProxy */; }; 4FD0AB361B74869600A82496 /* PBXTargetDependency */ = { @@ -8368,6 +8429,11 @@ target = D6BB4FBB1C1695EC000DC0A2 /* speechlistener */; targetProxy = D6BB4FCB1C16965C000DC0A2 /* PBXContainerItemProxy */; }; + D6C32CD527DDD78F001AA22E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D6C32CC527DDD47F001AA22E /* spaces */; + targetProxy = D6C32CD427DDD78F001AA22E /* PBXContainerItemProxy */; + }; D6FD56921C04230200BAE238 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D6FD56851C0422B400BAE238 /* console */; @@ -9166,6 +9232,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 4F0C1C77272F3EE9002CA157 /* Extensions-Base.xcconfig */; buildSettings = { + LD_DYLIB_INSTALL_NAME = "$(DYLIB_INSTALL_NAME_BASE:standardizepath)/$(EXECUTABLE_PATH)"; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Profile; @@ -9995,6 +10062,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 4F0C1C77272F3EE9002CA157 /* Extensions-Base.xcconfig */; buildSettings = { + LD_DYLIB_INSTALL_NAME = "$(DYLIB_INSTALL_NAME_BASE:standardizepath)/$(EXECUTABLE_PATH)"; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -10003,6 +10071,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 4F0C1C77272F3EE9002CA157 /* Extensions-Base.xcconfig */; buildSettings = { + LD_DYLIB_INSTALL_NAME = "$(DYLIB_INSTALL_NAME_BASE:standardizepath)/$(EXECUTABLE_PATH)"; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; @@ -10797,6 +10866,30 @@ }; name = Release; }; + D6C32CCC27DDD47F001AA22E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4F0C1C77272F3EE9002CA157 /* Extensions-Base.xcconfig */; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + D6C32CCD27DDD47F001AA22E /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4F0C1C77272F3EE9002CA157 /* Extensions-Base.xcconfig */; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + D6C32CCE27DDD47F001AA22E /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4F0C1C77272F3EE9002CA157 /* Extensions-Base.xcconfig */; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; D6E164DA1F4C262300B85D8A /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 4F0C1C77272F3EE9002CA157 /* Extensions-Base.xcconfig */; @@ -11334,7 +11427,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - 4FD0AB1A1B7484D700A82496 /* Build configuration list for PBXNativeTarget "spaceswatcher" */ = { + 4FD0AB1A1B7484D700A82496 /* Build configuration list for PBXNativeTarget "spaces_watcher" */ = { isa = XCConfigurationList; buildConfigurations = ( 4FD0AB1B1B7484D700A82496 /* Debug */, @@ -11784,6 +11877,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + D6C32CCB27DDD47F001AA22E /* Build configuration list for PBXNativeTarget "spaces" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D6C32CCC27DDD47F001AA22E /* Debug */, + D6C32CCD27DDD47F001AA22E /* Profile */, + D6C32CCE27DDD47F001AA22E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; D6E164D91F4C262300B85D8A /* Build configuration list for PBXNativeTarget "fsxattr" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Hammerspoon/setup.lua b/Hammerspoon/setup.lua index e219f4253..ab5536046 100644 --- a/Hammerspoon/setup.lua +++ b/Hammerspoon/setup.lua @@ -51,7 +51,7 @@ package.preload['hs.network.ping'] = preload 'hs.network_ping' package.preload['hs.pasteboard.watcher'] = preload 'hs.libpasteboardwatcher' package.preload['hs.screen.watcher'] = preload 'hs.libscreenwatcher' package.preload['hs.socket.udp'] = preload 'hs.libsocketudp' -package.preload['hs.spaces.watcher'] = preload 'hs.libspaceswatcher' +package.preload['hs.spaces.watcher'] = preload 'hs.libspaces_watcher' package.preload['hs.uielement.watcher'] = preload 'hs.libuielementwatcher' package.preload['hs.usb.watcher'] = preload 'hs.libusbwatcher' package.preload['hs.webview.datastore'] = preload 'hs.libwebviewdatastore' diff --git a/extensions/spaces/internal.m b/extensions/spaces/internal.m deleted file mode 100644 index e69de29bb..000000000 diff --git a/extensions/spaces/libspaces.m b/extensions/spaces/libspaces.m new file mode 100644 index 000000000..fa725d2f1 --- /dev/null +++ b/extensions/spaces/libspaces.m @@ -0,0 +1,274 @@ +@import Cocoa ; +@import LuaSkin ; + +#import "private.h" + +static const char * const USERDATA_TAG = "hs.spaces" ; +static LSRefTable refTable = LUA_NOREF; + +static NSRegularExpression *regEx_UUID ; + +static int g_connection ; + +#pragma mark - Support Functions and Classes + +#pragma mark - Module Functions + +/// hs.spaces.screensHaveSeparateSpaces() -> bool +/// Function +/// Determine if the user has enabled the "Displays Have Separate Spaces" option within Mission Control. +/// +/// Parameters: +/// * None +/// +/// Returns: +/// * true or false representing the status of the "Displays Have Separate Spaces" option within Mission Control. +static int spaces_screensHaveSeparateSpaces(lua_State *L) { + LuaSkin *skin = [LuaSkin sharedWithState:L] ; + [skin checkArgs:LS_TBREAK] ; + lua_pushboolean(L, [NSScreen screensHaveSeparateSpaces]) ; + return 1 ; +} + +/// hs.spaces.data_managedDisplaySpaces() -> table | nil, error +/// Function +/// Returns a table containing information about the managed display spaces +/// +/// Parameters: +/// * None +/// +/// Returns: +/// * a table containing information about all of the displays and spaces managed by the OS. +/// +/// Notes: +/// * the format and detail of this table is too complex and varied to describe here; suffice it to say this is the workhorse for this module and a careful examination of this table may be informative, but is not required in the normal course of using this module. +static int spaces_managedDisplaySpaces(lua_State *L) { + LuaSkin *skin = [LuaSkin sharedWithState:L] ; + [skin checkArgs:LS_TBREAK] ; + CFArrayRef managedDisplaySpaces = SLSCopyManagedDisplaySpaces(g_connection) ; + if (managedDisplaySpaces) { + [skin pushNSObject:(__bridge NSArray *)managedDisplaySpaces withOptions:LS_NSDescribeUnknownTypes] ; + CFRelease(managedDisplaySpaces) ; + } else { + lua_pushnil(L) ; + lua_pushstring(L, "SLSCopyManagedDisplaySpaces returned NULL") ; + return 2 ; + } + return 1 ; +} + + +/// hs.spaces.focusedSpace() -> integer +/// Function +/// Returns the space ID of the currently focused space +/// +/// Parameters: +/// * None +/// +/// Returns: +/// * the space ID for the currently focused space. The focused space is the currently active space on the currently active screen (i.e. that the user is working on) +/// +/// Notes: +/// * *usually* the currently active screen will be returned by `hs.screen.mainScreen()`; however some full screen applications may have focus without updating which screen is considered "main". You can use this function, and look up the screen UUID with [hs.spaces.spaceDisplay](#spaceDisplay) to determine the "true" focused screen if required. +static int spaces_getActiveSpace(lua_State* L) { + LuaSkin *skin = [LuaSkin sharedWithState:L] ; + [skin checkArgs:LS_TBREAK] ; + lua_pushinteger(L, (lua_Integer)SLSGetActiveSpace(g_connection)) ; + return 1 ; +} + +/// hs.spaces.windowsForSpace(spaceID) -> table | nil, error +/// Function +/// Returns a table containing the window IDs of *all* windows on the specified space +/// +/// Parameters: +/// * `spaceID` - an integer specifying the ID of the space +/// +/// Returns: +/// * a table containing the window IDs for *all* windows on the specified space +/// +/// Notes: +/// * the table returned has its __tostring metamethod set to `hs.inspect` to simplify inspecting the results when using the Hammerspoon Console. +/// * The list of windows includes all items which are considered "windows" by macOS -- this includes visual elements usually considered unimportant like overlays, tooltips, graphics, off-screen windows, etc. so expect a lot of false positives in the results. +/// * In addition, due to the way Accessibility objects work, only those window IDs that are present on the currently visible spaces will be finable with `hs.window` or exist within `hs.window.allWindows()`. +/// * This function *will* prune Hammerspoon canvas elements from the list because we "own" these and can identify their window ID's programmatically. This does not help with other applications, however. +/// * Reviewing how third-party applications have generally pruned this list, I believe it will be necessary to use `hs.window.filter` to prune the list and access `hs.window` objects that are on the non-visible spaces. +/// * as `hs.window.filter` is scheduled to undergo a re-write soon to (hopefully) dramatically speed it up, I am providing this function *as is* at present for those who wish to experiment with it; however, I hope to make it more useful in the coming months and the contents may change in the future (the format won't, but hopefully the useless extras will disappear requiring less pruning logic on your end). +static int spaces_windowsForSpace(lua_State *L) { // NOTE: wrapped in init.lua + LuaSkin *skin = [LuaSkin sharedWithState:L] ; + [skin checkArgs:LS_TNUMBER | LS_TINTEGER, LS_TBOOLEAN | LS_TOPTIONAL, LS_TBREAK] ; + uint64_t sid = (uint64_t)lua_tointeger(L, 1) ; + BOOL includeMinimized = (lua_gettop(L) > 1) ? (BOOL)(lua_toboolean(L, 2)) : YES ; + + uint32_t owner = 0 ; + uint32_t options = includeMinimized ? 0x7 : 0x2 ; + uint64_t setTags = 0 ; + uint64_t clearTags = 0 ; + + int type = SLSSpaceGetType(g_connection, sid) ; + if (type != 0 && type != 4) { + lua_pushnil(L) ; + lua_pushstring(L, "not a user or fullscreen managed space") ; + return 2 ; + } + + NSArray *spacesList = @[ [NSNumber numberWithUnsignedLongLong:sid] ] ; + + CFArrayRef windowListRef = SLSCopyWindowsWithOptionsAndTags(g_connection, owner, (__bridge CFArrayRef)spacesList, options, &setTags, &clearTags) ; + + if (windowListRef) { + [skin pushNSObject:(__bridge NSArray *)windowListRef] ; + lua_newtable(L) ; + [skin requireModule:"hs.inspect"] ; lua_setfield(L, -2, "__tostring") ; + lua_setmetatable(L, -2) ; + + CFRelease(windowListRef) ; + } else { + lua_pushnil(L) ; + lua_pushfstring(L, "SLSCopyWindowsWithOptionsAndTags returned NULL for %d", sid) ; + return 2 ; + } + return 1 ; +} + +/// hs.spaces.moveWindowToSpace(window, spaceID) -> true | nil, error +/// Function +/// Moves the window with the specified windowID to the space specified by spaceID. +/// +/// Parameters: +/// * `window` - an integer specifying the ID of the window, or an `hs.window` object +/// * `spaceID` - an integer specifying the ID of the space +/// +/// Returns: +/// * true if the window was moved; otherwise nil and an error message. +/// +/// Notes: +/// * a window can only be moved from a user space to another user space -- you cannot move the window of a full screen (or tiled) application to another space and you cannot move a window *to* the same space as a full screen application. +static int spaces_moveWindowToSpace(lua_State *L) { // NOTE: wrapped in init.lua + LuaSkin *skin = [LuaSkin sharedWithState:L] ; + [skin checkArgs:LS_TNUMBER | LS_TINTEGER, LS_TNUMBER | LS_TINTEGER, LS_TBREAK] ; + uint32_t wid = (uint32_t)lua_tointeger(L, 1) ; + uint64_t sid = (uint64_t)lua_tointeger(L, 2) ; + + if (SLSSpaceGetType(g_connection, sid) != 0) { + lua_pushnil(L) ; + lua_pushfstring(L, "target space ID %d does not refer to a user space", sid) ; + return 2 ; + } + + NSArray *windows = @[ [NSNumber numberWithUnsignedLong:wid] ] ; + // 0x7 : kCGSAllSpacesMask = CGSSpaceIncludesUser | CGSSpaceIncludesOthers | CGSSpaceIncludesCurrent + // from https://github.com/NUIKit/CGSInternal/blob/master/CGSSpace.h + CFArrayRef spacesList = SLSCopySpacesForWindows(g_connection, 0x7, (__bridge CFArrayRef)windows) ; + if (spacesList) { + if (![(__bridge NSArray *)spacesList containsObject:[NSNumber numberWithUnsignedLongLong:sid]]) { + NSNumber *sourceSpace = [(__bridge NSArray *)spacesList firstObject] ; + if (SLSSpaceGetType(g_connection, sourceSpace.unsignedLongLongValue) != 0) { + lua_pushnil(L) ; + lua_pushfstring(L, "source space for windowID %d is not a user space", wid) ; + return 2 ; + } + SLSMoveWindowsToManagedSpace(g_connection, (__bridge CFArrayRef)windows, sid) ; + } + lua_pushboolean(L, true) ; + CFRelease(spacesList) ; + } else { + lua_pushnil(L) ; + lua_pushfstring(L, "SLSCopySpacesForWindows returned NULL for window ID %d", wid) ; + return 2 ; + } + return 1 ; +} + +/// hs.spaces.windowSpaces(window) -> table | nil, error +/// Function +/// Returns a table containing the space IDs for all spaces that the specified window is on. +/// +/// Parameters: +/// * `window` - an integer specifying the ID of the window, or an `hs.window` object +/// +/// Returns: +/// * a table containing the space IDs of all spaces the window is on, or nil and an error message if an error occurs. +/// +/// Notes: +/// * the table returned has its __tostring metamethod set to `hs.inspect` to simplify inspecting the results when using the Hammerspoon Console. +/// * If the window ID does not specify a valid window, then an empty array will be returned. +/// * For most windows, this will be a single element table; however some applications may create "sticky" windows that may appear on more than one space. +/// * For example, the container windows for `hs.canvas` objects which have the `canJoinAllSpaces` behavior set will appear on all spaces and the table returned by this function will contain all spaceIDs for the screen which displays the canvas. +static int spaces_windowSpaces(lua_State *L) { + LuaSkin *skin = [LuaSkin sharedWithState:L] ; + [skin checkArgs:LS_TNUMBER | LS_TINTEGER, LS_TBREAK] ; + uint32_t wid = (uint32_t)lua_tointeger(L, 1) ; + + NSArray *windows = @[ [NSNumber numberWithUnsignedLong:wid] ] ; + // 0x7 : kCGSAllSpacesMask = CGSSpaceIncludesUser | CGSSpaceIncludesOthers | CGSSpaceIncludesCurrent + // from https://github.com/NUIKit/CGSInternal/blob/master/CGSSpace.h + CFArrayRef spacesList = SLSCopySpacesForWindows(g_connection, 0x7, (__bridge CFArrayRef)windows) ; + if (spacesList) { + [skin pushNSObject:(__bridge NSArray *)spacesList] ; + lua_newtable(L) ; + [skin requireModule:"hs.inspect"] ; lua_setfield(L, -2, "__tostring") ; + lua_setmetatable(L, -2) ; + + CFRelease(spacesList) ; + } else { + lua_pushnil(L) ; + lua_pushfstring(L, "SLSCopySpacesForWindows returned NULL for window ID %d", wid) ; + return 2 ; + } + return 1 ; +} + +static int spaces_coreDesktopSendNotification(lua_State *L) { + LuaSkin *skin = [LuaSkin sharedWithState:L] ; + [skin checkArgs:LS_TSTRING, LS_TBREAK] ; + NSString *message = [skin toNSObjectAtIndex:1] ; + + lua_pushinteger(L, (lua_Integer)(CoreDockSendNotification((__bridge CFStringRef)message, 0))) ; + return 1 ; +} + +#pragma mark - Module Constants + +#pragma mark - Hammerspoon/Lua Infrastructure + +// Functions for returned object when module loads +static luaL_Reg moduleLib[] = { + {"screensHaveSeparateSpaces", spaces_screensHaveSeparateSpaces}, + {"data_managedDisplaySpaces", spaces_managedDisplaySpaces}, + + // hs.spaces.activeSpaceOnScreen(hs.screen.mainScreen()) wrong for full screen apps, so keep + {"focusedSpace", spaces_getActiveSpace}, + + {"moveWindowToSpace", spaces_moveWindowToSpace}, + {"windowsForSpace", spaces_windowsForSpace}, + {"windowSpaces", spaces_windowSpaces}, + + {"_coreDesktopNotification", spaces_coreDesktopSendNotification}, + + {NULL, NULL} +}; + +// // Metatable for module, if needed +// static const luaL_Reg module_metaLib[] = { +// {"__gc", meta_gc}, +// {NULL, NULL} +// }; + +int luaopen_hs_libspaces(lua_State* L) { + LuaSkin *skin = [LuaSkin sharedWithState:L] ; + refTable = [skin registerLibrary:USERDATA_TAG functions:moduleLib metaFunctions:nil] ; // or module_metaLib + + g_connection = SLSMainConnectionID() ; + + NSError *error = nil ; + regEx_UUID = [NSRegularExpression regularExpressionWithPattern:@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + options:NSRegularExpressionCaseInsensitive + error:&error] ; + if (error) { + regEx_UUID = nil ; + [skin logError:[NSString stringWithFormat:@"%s.luaopen - unable to create UUID regular expression: %@", USERDATA_TAG, error.localizedDescription]] ; + } + + return 1; +} diff --git a/extensions/spaces/libspaces_watcher.m b/extensions/spaces/libspaces_watcher.m index 4ab49fe0a..ed378430b 100644 --- a/extensions/spaces/libspaces_watcher.m +++ b/extensions/spaces/libspaces_watcher.m @@ -1,7 +1,7 @@ -#import -#import -#import -#import +@import Foundation ; +@import Cocoa ; +@import CoreGraphics ; +@import LuaSkin ; /// === hs.spaces.watcher === /// @@ -25,7 +25,7 @@ - (id)initWithObject:(spacewatcher_t*)object; @implementation SpaceWatcher - (id)initWithObject:(spacewatcher_t*)object { - if (self = [super init]) { + if ((self = [super init])) { self.object = object; } return self; @@ -54,9 +54,10 @@ - (void)spaceChanged:(NSNotification*)notification { for (NSMutableDictionary *thisWindow in windowsInSpace) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" - if ([thisWindow objectForKey:(id)kCGWindowWorkspace]) { - currentSpace = [[thisWindow objectForKey:(id)kCGWindowWorkspace] intValue]; + NSNumber *potentialID = [thisWindow objectForKey:(id)kCGWindowWorkspace] ; #pragma clang diagnostic pop + if (potentialID) { + currentSpace = [potentialID intValue]; break; } } @@ -178,7 +179,7 @@ static int userdata_tostring(lua_State* L) { {NULL, NULL} }; -int luaopen_hs_libspaceswatcher(lua_State* L) { +int luaopen_hs_libspaces_watcher(lua_State* L) { LuaSkin *skin = [LuaSkin sharedWithState:L]; refTable = [skin registerLibraryWithObject:USERDATA_TAG functions:watcherlib metaFunctions:nil objectFunctions:watcher_objectlib]; diff --git a/extensions/spaces/private.h b/extensions/spaces/private.h new file mode 100644 index 000000000..9ae04c72f --- /dev/null +++ b/extensions/spaces/private.h @@ -0,0 +1,27 @@ +#pragma once + +// Most of these mirror similarly named functions in the CGS* name space used by my previous hs._asm.undocumented.spaces, but as the +// SkyLight Server framework seems to be cropping up more and more with new OS features (esp the virtual touchbar), I suspect it may +// be more "future proof"... I'm going to use these instead. + +extern int SLSMainConnectionID(void) ; +extern CGError CoreDockSendNotification(CFStringRef notification, int unknown); + +extern CFArrayRef SLSCopyManagedDisplaySpaces(int cid) ; +extern int SLSSpaceGetType(int cid, uint64_t sid); +extern CFArrayRef SLSCopyWindowsWithOptionsAndTags(int cid, uint32_t owner, CFArrayRef spaces, uint32_t options, uint64_t *set_tags, uint64_t *clear_tags); +extern void SLSMoveWindowsToManagedSpace(int cid, CFArrayRef window_list, uint64_t sid); +extern CFArrayRef SLSCopySpacesForWindows(int cid, int selector, CFArrayRef window_list); + +// extern uint64_t SLSManagedDisplayGetCurrentSpace(int cid, CFStringRef uuid) ; +// extern CFStringRef SLSCopyManagedDisplayForSpace(int cid, uint64_t sid); +// extern CFStringRef SLSSpaceCopyName(int cid, uint64_t sid); +// extern CGError SLSProcessAssignToSpace(int cid, pid_t pid, uint64_t sid); +// extern CGError SLSProcessAssignToAllSpaces(int cid, pid_t pid); + + +// Not used in Yabai, but still potentially useful +extern uint64_t SLSGetActiveSpace(int cid) ; + +// no longer seems to work, even for regular space changes, so not using +// extern bool SLSManagedDisplayIsAnimating(int cid, CFStringRef uuid) ; diff --git a/extensions/spaces/spaces.lua b/extensions/spaces/spaces.lua index c633eddf7..1b9f0cc9b 100644 --- a/extensions/spaces/spaces.lua +++ b/extensions/spaces/spaces.lua @@ -1,9 +1,846 @@ --- === hs.spaces === --- ---- Controls for macOS Spaces. Currenly only used by `hs.spaces.watcher`. +--- This module provides some basic functions for controlling macOS Spaces. +--- +--- The functionality provided by this module is considered experimental and subject to change. By using a combination of private APIs and Accessibility hacks (via hs.axuielement), some basic functions for controlling the use of Spaces is possible with Hammerspoon, but there are some limitations and caveats. +--- +--- It should be noted that while the functions provided by this module have worked for some time in third party applications and in a previous experimental module that has received limited testing over the last few years, they do utilize some private APIs which means that Apple could change them at any time. +--- +--- The functions which allow you to create new spaes, remove spaces, and jump to a specific space utilize `hs.axuielement` and perform accessibility actions through the Dock application to manipulate Mission Control. Because we are essentially directing the Dock to perform User Interactions, there is some visual feedback which we cannot entirely suppress. You can minimize, but not entirely remove, this by enabling "Reduce motion" in System Preferences -> Accessibility -> Display. +--- +--- It is recommended that you also enable "Displays have separate Spaces" in System Preferences -> Mission Control. +--- +--- This module is a distillation of my previous `hs._asm.undocumented.spaces` module, changes inspired by reviewing the `Yabai` source, and some experimentation with `hs.axuielement`. If you require more sophisticated control, I encourage you to check out https://github.com/koekeishiya/yabai -- it does require some additional setup (changes to SIP, possibly edits to `sudoers`, etc.) but may be worth the extra steps for some power users. + +-- TODO: +-- does this work if "Displays have Separate Spaces" isn't checked in System Preferences -> +-- Mission Control? What changes, and can we work around it? +-- +-- need working hs.window.filter (or replacement) for pruning windows list and making use of other space windows + +-- I think we're probably done with Yabai duplication -- basic functionality desired is present, minus window id pruning +-- + yabai supports *some* stuff on M1 without injection... investigate +-- * move window to space -- according to M1 tracking issue +-- + ids of windows on other spaces -- partial; see hs.window.filter comment above + +local USERDATA_TAG = "hs.spaces" +local module = require(table.concat({ USERDATA_TAG:match("^([%w%._]+%.)([%w_]+)$") }, "lib")) +module.watcher = require(USERDATA_TAG .. ".watcher") + +-- settings with periods in them can't be watched via KVO with hs.settings.watchKey, so +-- in general it's a good idea not to include periods +local SETTINGS_TAG = USERDATA_TAG:gsub("%.", "_") +local settings = require("hs.settings") +-- local log = require("hs.logger").new(USERDATA_TAG, settings.get(SETTINGS_TAG .. "_logLevel") or "warning") + +local axuielement = require("hs.axuielement") +local application = require("hs.application") +local screen = require("hs.screen") +local inspect = require("hs.inspect") +local timer = require("hs.timer") + +local host = require("hs.host") +local fs = require("hs.fs") +local plist = require("hs.plist") + +-- private variables and methods ----------------------------------------- + +-- locale handling for buttons representing spaces in Mission Control + +local AXExitToDesktop, AXExitToFullscreenDesktop +local getDockExitTemplates = function() + local localesToSearch = host.locale.preferredLanguages() or {} + -- make a copy since preferredLanguages uses ls.makeConstantsTable for "friendly" display in console + localesToSearch = table.move(localesToSearch, 1, #localesToSearch, 1, {}) + table.insert(localesToSearch, host.locale.current()) + local path = application("Dock"):path() .. "/Contents/Resources" + + local locale = "" + while #localesToSearch > 0 do + locale = table.remove(localesToSearch, 1):gsub("%-", "_") + while #locale > 0 do + if fs.attributes(path .. "/" .. locale .. ".lproj/Accessibility.strings") then break end + locale = locale:match("^(.-)_?[^_]+$") + end + if #locale > 0 then break end + end + + if #locale == 0 then locale = "en" end -- fallback to english + + local contents = plist.read(path .. "/" .. locale .. ".lproj/Accessibility.strings") + AXExitToDesktop = "^" .. contents.AXExitToDesktop:gsub("%%@", "(.-)") .. "$" + AXExitToFullscreenDesktop = "^" .. contents.AXExitToFullscreenDesktop:gsub("%%@", "(.-)") .. "$" +end + +local localeChange_identifier = host.locale.registerCallback(getDockExitTemplates) +getDockExitTemplates() -- set initial values + +local spacesNameFromButtonName = function(name) + return name:match(AXExitToFullscreenDesktop) or name:match(AXExitToDesktop) or name +end + +-- now onto the rest of the local functions +local _dockElement +local getDockElement = function() + -- if the Dock is killed for some reason, its element will be invalid + if not (_dockElement and _dockElement:isValid()) then + _dockElement = axuielement.applicationElement(application("Dock")) + end + return _dockElement +end + +local _missionControlGroup +local getMissionControlGroup = function() + if not (_missionControlGroup and _missionControlGroup:isValid()) then + _missionControlGroup = nil + local dockElement = getDockElement() + for _,v in ipairs(dockElement) do + if v.AXIdentifier == "mc" then + _missionControlGroup = v + break + end + end + end + return _missionControlGroup +end + +local openMissionControl = function() + local missionControlGroup = getMissionControlGroup() + if not missionControlGroup then module.toggleMissionControl() end +end + +local closeMissionControl = function() + local missionControlGroup = getMissionControlGroup() + if missionControlGroup then module.toggleMissionControl() end +end + +local findSpacesSubgroup = function(targetIdentifier, screenID) + local missionControlGroup, initialTime = nil, os.time() + while not missionControlGroup and (os.time() - initialTime) < 2 do + missionControlGroup = getMissionControlGroup() + end + if not missionControlGroup then + return nil, "unable to get Mission Control data from the Dock" + end + + local mcChildren = missionControlGroup:attributeValue("AXChildren") or {} + local mcDisplay = table.remove(mcChildren) + while mcDisplay do + if mcDisplay.AXIdentifier == "mc.display" and mcDisplay.AXDisplayID == screenID then + break + end + mcDisplay = table.remove(mcChildren) + end + if not mcDisplay then + return nil, "no display with specified id found" + end + + local mcDisplayChildren = mcDisplay:attributeValue("AXChildren") or {} + local mcSpaces = table.remove(mcDisplayChildren) + while mcSpaces do + if mcSpaces.AXIdentifier == "mc.spaces" then + break + end + mcSpaces = table.remove(mcDisplayChildren) + end + if not mcSpaces then + return nil, "unable to locate mc.spaces group for display" + end + + local mcSpacesChildren = mcSpaces:attributeValue("AXChildren") or {} + local targetChild = table.remove(mcSpacesChildren) + while targetChild do + if targetChild.AXIdentifier == targetIdentifier then break end + targetChild = table.remove(mcSpacesChildren) + end + if not targetChild then + return nil, string.format("unable to find target %s for display", targetIdentifier) + end + return targetChild +end + +local waitForMissionControl = function() + -- delay to make sure Mission Control has stabilized + local time = timer.secondsSinceEpoch() + while timer.secondsSinceEpoch() - time < module.MCwaitTime do + -- twiddle thumbs, calculate more digits of pi, whatever floats your boat... + end +end + +-- Public interface ------------------------------------------------------ + +--- hs.spaces.data_missionControlAXUIElementData(callback) -> None +--- Function +--- Generate a table containing the results of `hs.axuielement.buildTree` on the Mission Control Accessibility group of the Dock. +--- +--- Parameters: +--- * `callback` - a callback function that should expect a table as the results. The table will be formatted as described in the documentation for `hs.axuielement.buildTree`. +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Like [hs.spaces.data_managedDisplaySpaces](#data_managedDisplaySpaces), this function is not required for general usage of this module; rather it is provided for those who wish to examine the internal data that makes this module possible more closely to see if there might be other information or functionality that they would like to explore. +--- * Getting Accessibility elements for Mission Control is somewhat tricky -- they only exist when the Mission Control display is visible, which is the exact time that you can't examine them. What this function does is trigger Mission Control and then builds a tree of the elements, capturing all of the properties and property values while the elements are valid, closes Mission Control, and then returns the results in a table by invoking the provided callback function. +--- * Note that the `hs.axuielement` objects within the table returned will be invalid by the time you can examine them -- this is why the attributes and values will also be contained in the resulting tree. +--- * Example usage: `hs.spaces.data_missionControlAXUIElementData(function(results) hs.console.clearConsole() ; print(hs.inspect(results)) end)` +module.data_missionControlAXUIElementData = function(callback) + assert( + type(callback) == "nil" or type(callback) == "function" or (getmetatable(callback) or {}).__call, + "callback must be nil or a function" + ) + + openMissionControl() + local missionControlGroup, initialTime = nil, os.time() + while not missionControlGroup and (os.time() - initialTime) < 2 do + missionControlGroup = getMissionControlGroup() + end + if not missionControlGroup then + return nil, "unable to get Mission Control data from the Dock" + end + + -- delay to make sure Mission Control has stabilized + waitForMissionControl() + + local tree -- luacheck:ignore + tree = missionControlGroup:buildTree(function(_, results) + tree = nil + closeMissionControl() + callback(results) + end) +end + +--- hs.spaces.MCwaitTime +--- Variable +--- Specifies how long to delay before performing the accessibility actions for [hs.spaces.gotoSpace](#gotoSpace) and [hs.spaces.removeSpace](#removeSpace) +--- +--- Notes: +--- * The above mentioned functions require that the Mission Control accessibility objects be fully formed before the necessary action can be triggered. This variable specifies how long to delay before performing the action to complete the function. Experimentation on my machine has found that 0.3 seconds provides sufficient time for reliable functionality. +--- * If you find that the above mentioned functions do not work reliably with your setup, you can try adjusting this variable upwards -- the down side is that the larger this value is, the longer the Mission Control display is visible before returning the user to what they were working on. +--- * Once you have found a value that works reliably on your system, you can use [hs.spaces.setDefaultMCwaitTime](#setDefaultMCwaitTime) to make it the default value for your system each time the `hs.spaces` module is loaded. +module.MCwaitTime = settings.get(SETTINGS_TAG .. "_MCwaitTime") or 0.3 + +--- hs.spaces.setDefaultMCwaitTime([time]) -> None +--- Function +--- Sets the initial value for [hs.spaces.MCwaitTime](#MCwaitTime) to be set to when this module first loads. +--- +--- Parameters: +--- * `time` - an optional number greater than 0 specifying the initial default for [hs.spaces.MCwaitTime](#MCwaitTime). If you do not specify a value, then the current value of [hs.spaces.MCwaitTime](#MCwaitTime) is used. +--- +--- Returns: +--- * None +--- +--- Notes: +--- * this function uses the `hs.settings` module to store the default time in the key "hs_spaces_MCwaitTime". +module.setDefaultMCwaitTime = function(qt) + qt = qt or module.MCwaitTime + assert(type(qt) == "number" and qt > 0, "default wait time must be a number greater than 0") + settings.set(SETTINGS_TAG .. "_MCwaitTime", qt) +end + +--- hs.spaces.toggleShowDesktop() -> None +--- Function +--- Toggles moving all windows on/off screen to display the desktop underneath. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * this is the same functionality as provided by the System Preferences -> Mission Control -> Hot Corners... -> Desktop setting, the Show Desktop touchbar icon, or the Show Desktop trackpad swipe gesture (Spread with thumb and three fingers). +module.toggleShowDesktop = function() module._coreDesktopNotification("com.apple.showdesktop.awake") end + +--- hs.spaces.toggleMissionControl() -> None +--- Function +--- Toggles the Mission Control display +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * this is the same functionality as provided by the System Preferences -> Mission Control -> Hot Corners... -> Mission Control setting, the Mission Control touchbar icon, or the Mission Control trackpad swipe gesture (3 or 4 fingers up). +module.toggleMissionControl = function() module._coreDesktopNotification("com.apple.expose.awake") end + +--- hs.spaces.toggleAppExpose() -> None +--- Function +--- Toggles the current applications Exposé display +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * this is the same functionality as provided by the System Preferences -> Mission Control -> Hot Corners... -> Application Windows setting or the App Exposé trackpad swipe gesture (3 or 4 fingers down). +module.toggleAppExpose = function() module._coreDesktopNotification("com.apple.expose.front.awake") end + +--- hs.spaces.toggleLaunchPad() -> None +--- Function +--- Toggles the Launch Pad display. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * this is the same functionality as provided by the System Preferences -> Mission Control -> Hot Corners... -> Launch Pad setting, the Launch Pad touchbar icon, or the Launch Pad trackpad swipe gesture (Pinch with thumb and three fingers). +module.toggleLaunchPad = function() module._coreDesktopNotification("com.apple.launchpad.toggle") end + +--- hs.spaces.openMissionControl() -> None +--- Function +--- Opens the Mission Control display +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Does nothing if the Mission Control display is already visible. +--- * This function uses Accessibility features provided by the Dock to open up Mission Control and is used internally when performing the [hs.spaces.gotoSpace](#gotoSpace), [hs.spaces.addSpaceToScreen](#addSpaceToScreen), and [hs.spaces.removeSpace](#removeSpace) functions. +--- * It is unlikely you will need to invoke this by hand, and the public interface to this function may go away in the future. +module.openMissionControl = openMissionControl + +--- hs.spaces.closeMissionControl() -> None +--- Function +--- Opens the Mission Control display +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * None +--- +--- Notes: +--- * Does nothing if the Mission Control display is not currently visible. +--- * This function uses Accessibility features provided by the Dock to close Mission Control and is used internally when performing the [hs.spaces.gotoSpace](#gotoSpace), [hs.spaces.addSpaceToScreen](#addSpaceToScreen), and [hs.spaces.removeSpace](#removeSpace) functions. +--- * It is possible to invoke the above mentioned functions and prevent them from auto-closing Mission Control -- this may be useful if you wish to perform multiple actions and want to minimize the visual side-effects. You can then use this function when you are done. +module.closeMissionControl = closeMissionControl + +--- hs.spaces.spacesForScreen([screen]) -> table | nil, error +--- Function +--- Returns a table containing the IDs of the spaces for the specified screen in their current order. +--- +--- Parameters: +--- * `screen` - an optional screen specification identifying the screen to return the space array for. The screen may be specified by it's ID (`hs.screen:id()`), it's UUID (`hs.screen:getUUID()`), or as an `hs.screen` object. If no screen is specified, the screen returned by `hs.screen.mainScreen()` is used. +--- +--- Returns: +--- * a table containing space IDs for the spaces for the screen, or nil and an error message if there is an error. +--- +--- Notes: +--- * the table returned has its __tostring metamethod set to `hs.inspect` to simplify inspecting the results when using the Hammerspoon Console. +module.spacesForScreen = function(...) + local args, screenID = { ... }, nil + assert(#args < 2, "expected no more than 1 argument") + if #args > 0 then screenID = args[1] end + if screenID == nil then + screenID = screen.mainScreen():getUUID() + elseif getmetatable(screenID) == hs.getObjectMetatable("hs.screen") then + screenID = screenID:getUUID() + elseif math.type(screenID) == "integer" then + for _,v in ipairs(screen.allScreens()) do + if v:id() == screenID then + screenID = v:getUUID() + break + end + end + if math.type(screenID) == "integer" then error("not a valid screen ID") end + elseif not (type(screenID) == "string" and #screenID == 36) then + error("screen must be specified as UUID, screen ID, or hs.screen object") + end + + local managedDisplayData, errMsg = module.data_managedDisplaySpaces() + if managedDisplayData == nil then return nil, errMsg end + for _, managedDisplay in ipairs(managedDisplayData) do + if managedDisplay["Display Identifier"] == screenID then + local results = {} + for _, space in ipairs(managedDisplay.Spaces) do + table.insert(results, space.ManagedSpaceID) + end + return setmetatable(results, { __tostring = inspect }) + end + end + return nil, "screen not found in managed displays" +end + +--- hs.spaces.allSpaces() -> table | nil, error +--- Function +--- Returns a Kay-Value table contining the IDs of all spaces for all screens. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * a key-value table in which the keys are the UUIDs for the current screens and the value for each key is a table of space IDs corresponding to the spaces for that screen. Returns nil and an error message if an error occurs. +--- +--- Notes: +--- * the table returned has its __tostring metamethod set to `hs.inspect` to simplify inspecting the results when using the Hammerspoon Console. +module.allSpaces = function(...) + local args = { ... } + assert(#args == 0, "expected no arguments") + local results = {} + for _, v in ipairs(screen.allScreens()) do + local screenID = v:getUUID() + if screenID then -- allScreens may still report a userdata for a screen that has been disconnected for a short while + local spacesForScreen, errMsg = module.spacesForScreen(screenID) + if not spacesForScreen then + return nil, string.format("%s for %s", errMsg, screenID) + end + results[screenID] = spacesForScreen + end + end + return setmetatable(results, { __tostring = inspect }) +end + +--- hs.spaces.activeSpaceOnScreen([screen]) -> integer | nil, error +--- Function +--- Returns the currently visible (active) space for the specified screen. +--- +--- Parameters: +--- * `screen` - an optional screen specification identifying the screen to return the active space for. The screen may be specified by it's ID (`hs.screen:id()`), it's UUID (`hs.screen:getUUID()`), or as an `hs.screen` object. If no screen is specified, the screen returned by `hs.screen.mainScreen()` is used. +--- +--- Returns: +--- * an integer specifying the ID of the space displayed, or nil and an error message if an error occurs. +module.activeSpaceOnScreen = function(...) + local args, screenID = { ... }, nil + assert(#args < 2, "expected no more than 1 argument") + if #args > 0 then screenID = args[1] end + if screenID == nil then + screenID = screen.mainScreen():getUUID() + elseif getmetatable(screenID) == hs.getObjectMetatable("hs.screen") then + screenID = screenID:getUUID() + elseif math.type(screenID) == "integer" then + for _,v in ipairs(screen.allScreens()) do + if v:id() == screenID then + screenID = v:getUUID() + break + end + end + if math.type(screenID) == "integer" then error("not a valid screen ID") end + elseif not (type(screenID) == "string" and #screenID == 36) then + error("screen must be specified as UUID, screen ID, or hs.screen object") + end + + local managedDisplayData, errMsg = module.data_managedDisplaySpaces() + if managedDisplayData == nil then return nil, errMsg end + for _, managedDisplay in ipairs(managedDisplayData) do + if managedDisplay["Display Identifier"] == screenID then + for _, space in ipairs(managedDisplay.Spaces) do + if space.ManagedSpaceID == managedDisplay["Current Space"].ManagedSpaceID then + return space.ManagedSpaceID + end + end + return nil, "space not found in specified display" + end + end + return nil, "screen not found in managed displays" +end + +--- hs.spaces.activeSpaces() -> table | nil, error +--- Function +--- Returns a key-value table specifying the active spaces for all screens. +--- +--- Parameters: +--- * None +--- +--- Returns: +--- * a key-value table in which the keys are the UUIDs for the current screens and the value for each key is the space ID of the active space for that display. +--- +--- Notes: +--- * the table returned has its __tostring metamethod set to `hs.inspect` to simplify inspecting the results when using the Hammerspoon Console. +module.activeSpaces = function(...) + local args = { ... } + assert(#args == 0, "expected no arguments") + local results = {} + for _, v in ipairs(screen.allScreens()) do + local screenID = v:getUUID() + if screenID then -- allScreens may still report a userdata for a screen that has been disconnected for a short while + local activeSpaceID, activeSpaceName = module.activeSpaceOnScreen(screenID) + if not activeSpaceID then + return nil, string.format("%s for %s", activeSpaceName, screenID) + end + results[screenID] = activeSpaceID + end + end + return setmetatable(results, { __tostring = inspect }) +end + +--- hs.spaces.spaceDisplay(spaceID) -> string | nil, error +--- Function +--- Returns the screen UUID for the screen that the specified space is on. +--- +--- Parameters: +--- * `spaceID` - an integer specifying the ID of the space +--- +--- Returns: +--- * a string specifying the UUID of the display the space is on, or nil and error message if an error occurs. +--- +--- Notes: +--- * the space does not have to be currently active (visible) to determine which screen the space belongs to. +module.spaceDisplay = function(...) + local args = { ... } + assert(#args == 1, "expected 1 argument") + local spaceID = args[1] + assert(math.type(spaceID) == "integer", "space id must be an integer") + + local managedDisplayData, errMsg = module.data_managedDisplaySpaces() + if managedDisplayData == nil then return nil, errMsg end + for _, managedDisplay in ipairs(managedDisplayData) do + for _, space in ipairs(managedDisplay.Spaces) do + if space.ManagedSpaceID == spaceID then + return managedDisplay["Display Identifier"] + end + end + end + return nil, "space not found in managed displays" +end + +--- hs.spaces.spaceType(spaceID) -> string | nil, error +--- Function +--- Returns a string indicating whether the space is a user space or a full screen/tiled application space. +--- +--- Parameters: +--- * `spaceID` - an integer specifying the ID of the space +--- +--- Returns: +--- * the string "user" if the space is a regular user space, or "fullscreen" if the space is a fullscreen or tiled window pair. Returns nil and an error message if the space does not refer to a valid managed space. +module.spaceType = function(...) + local args = { ... } + assert(#args == 1, "expected 1 argument") + local spaceID = args[1] + assert(math.type(spaceID) == "integer", "space id must be an integer") + + local managedDisplayData, errMsg = module.data_managedDisplaySpaces() + if managedDisplayData == nil then return nil, errMsg end + for _, managedDisplay in ipairs(managedDisplayData) do + for _, space in ipairs(managedDisplay.Spaces) do + if space.ManagedSpaceID == spaceID then + if space.type == 0 then + return "user" + elseif space.type == 4 then + return "fullscreen" + else + return nil, string.format("unknown space type %d", space.type) + end + end + end + end + return nil, "space not found in managed displays" +end + +-- documented in libspaces.m where the core logic of the function resides +local _moveWindowToSpace = module.moveWindowToSpace +module.moveWindowToSpace = function(...) + local args = { ... } + if #args > 0 then + if getmetatable(args[1]) == hs.getObjectMetatable("hs.window") then + args[1] = args[1]:id() + end + end + return _moveWindowToSpace(table.unpack(args)) +end + +-- documented in libspaces.m where the core logic of the function resides +local _windowSpaces = module.windowSpaces +module.windowSpaces = function(...) + local args = { ... } + if #args > 0 and getmetatable(args[1]) == hs.getObjectMetatable("hs.window") then + args[1] = args[1]:id() + end + return _windowSpaces(table.unpack(args)) +end + +-- documented in libspaces.m where the core logic of the function resides +local _windowsForSpace = module.windowsForSpace +module.windowsForSpace = function(...) + local results = { _windowsForSpace(...) } + local actual = results[1] + if actual then + -- prune known Hammerspoon "non-windows" (e.g. canvas) + local HS = application.applicationsForBundleID(hs.processInfo.bundleID)[1] + for _, vElement in ipairs(axuielement.applicationElement(HS)) do + if vElement.AXRole == "AXWindow" and vElement.AXSubrole:match("^AXUnknown") then + local asHSWindow = vElement:asHSWindow() + if asHSWindow then + local badID = asHSWindow:id() + for idx, vID in ipairs(actual) do + if vID == badID then + table.remove(actual, idx) + break + end + end + end + end + end + end + + return table.unpack(results) +end + +--- hs.spaces.missionControlSpaceNames([closeMC]) -> table | nil, error +--- Function +--- Returns a table containing the space names as they appear in Mission Control associated with their space ID. This is provided for informational purposes only -- all of the functions of this module use the spaceID to insure accuracy. +--- +--- Parameters: +--- * `closeMC` - an optional boolean, default true, specifying whether or not the Mission Control display should be closed after adding the new space. +--- +--- Returns: +--- * a key-value table in which the keys are the UUIDs for each screen and the value is a key-value table where the screen ID is the key and the Mission Control name of the space is the value. +--- +--- Notes: +--- * the table returned has its __tostring metamethod set to `hs.inspect` to simplify inspecting the results when using the Hammerspoon Console. +--- * This function works by opening up the Mission Control display and then grabbing the names from the Accessibility elements created. This is unavoidable. You can minimize, but not entirely remove, the visual shift to the Mission Control display by by enabling "Reduce motion" in System Preferences -> Accessibility -> Display. +--- * If you intend to perform multiple actions which require the Mission Control display ([hs.spaces.missionControlSpaceNames](#missionControlSpaceNames), [hs.spaces.addSpaceToScreen](#addSpaceToScreen), [hs.spaces.removeSpace](#removeSpace), or [hs.spaces.gotoSpace](#gotoSpace)), you can pass in `false` as the final argument to prevent the automatic closure of the Mission Control display -- this will reduce the visual side-affects to one transition instead of many. +--- * This function attempts to use the localization strings for the Dock application to properly determine the Mission Control names. If you find that it doesn't provide the correct values for your system, please provide the following information when submitting an issue: +--- * the desktop or application name(s) as they appear at the top of the Mission Control screen when you invoke it manually (or with `hs.spaces.toggleMissionControl()` entered into the Hammerspoon console). +--- * the output from the following commands, issued in the Hammerspoon console: +--- * `hs.host.operatingSystemVersionString()` +--- * `hs.host.locale.current()` +--- * `hs.inspect(hs.host.locale.preferredLanguages())` +--- * `hs.inspect(hs.host.locale.details())` +module.missionControlSpaceNames = function(...) + local args, closeMC = { ... }, true + assert(#args < 2, "expected no more than 1 arguments") + if #args == 1 then closeMC = args[1] end + assert(type(closeMC) == "boolean", "close flag must be boolean") + + local results = {} + openMissionControl() + + for _, vScreen in ipairs(screen.allScreens()) do + local screenUUID = vScreen:getUUID() + local screenID = vScreen:id() + if screenUUID and screenID then -- allScreens may still report a userdata for a screen that has been disconnected for a short while + local spacesForDisplay, mapping = module.spacesForScreen(screenUUID), {} + local mcSpacesList, errMsg = findSpacesSubgroup("mc.spaces.list", screenID) + if not mcSpacesList then + if closeMC then closeMissionControl() end + return nil, errMsg + end + + for idx, child in ipairs(mcSpacesList) do + mapping[spacesForDisplay[idx]] = spacesNameFromButtonName(child.AXDescription) + end + + results[screenUUID] = mapping + end + end + + if closeMC then closeMissionControl() end + return setmetatable(results, { __tostring = inspect }) +end + +--- hs.spaces.addSpaceToScreen([screen], [closeMC]) -> true | nil, errMsg +--- Function +--- Adds a new space on the specified screen +--- +--- Parameters: +--- * `screen` - an optional screen specification identifying the screen to create the new space on. The screen may be specified by it's ID (`hs.screen:id()`), it's UUID (`hs.screen:getUUID()`), or as an `hs.screen` object. If no screen is specified, the screen returned by `hs.screen.mainScreen()` is used. +--- * `closeMC` - an optional boolean, default true, specifying whether or not the Mission Control display should be closed after adding the new space. +--- +--- Returns: +--- * true on success; otherwise return nil and an error message +--- +--- Notes: +--- * This function creates a new space by opening up the Mission Control display and then programmatically invoking the button to add a new space. This is unavoidable. You can minimize, but not entirely remove, the visual shift to the Mission Control display by by enabling "Reduce motion" in System Preferences -> Accessibility -> Display. +--- * If you intend to perform multiple actions which require the Mission Control display (([hs.spaces.missionControlSpaceNames](#missionControlSpaceNames), [hs.spaces.addSpaceToScreen](#addSpaceToScreen), [hs.spaces.removeSpace](#removeSpace), or [hs.spaces.gotoSpace](#gotoSpace)), you can pass in `false` as the final argument to prevent the automatic closure of the Mission Control display -- this will reduce the visual side-affects to one transition instead of many. +module.addSpaceToScreen = function(...) + local args, screenID, closeMC = { ... }, nil, true + assert(#args < 3, "expected no more than 2 arguments") + if #args == 1 then + if type(args[1]) ~= "boolean" then + screenID = args[1] + else + closeMC = args[1] + end + elseif #args > 1 then + screenID, closeMC = table.unpack(args) + end + if screenID == nil then + screenID = screen.mainScreen():id() + elseif getmetatable(screenID) == hs.getObjectMetatable("hs.screen") then + screenID = screenID:id() + elseif type(screenID) == "string" and #screenID == 36 then + for _,v in ipairs(screen.allScreens()) do + if v:getUUID() == screenID then + screenID = v:id() + break + end + end + end + assert(math.type(screenID) == "integer", "screen id must be an integer") + assert(type(closeMC) == "boolean", "close flag must be boolean") + + openMissionControl() + local mcSpacesAdd, errMsg = findSpacesSubgroup("mc.spaces.add", screenID) + if not mcSpacesAdd then + if closeMC then closeMissionControl() end + return nil, errMsg + end + + local status, errMsg2 = mcSpacesAdd:doAXPress() + + if closeMC then closeMissionControl() end + if status then + return true + else + return nil, errMsg2 + end +end + +--- hs.spaces.gotoSpace(spaceID) -> true | nil, errMsg +--- Function +--- Change to the specified space. +--- +--- Parameters: +--- * `spaceID` - an integer specifying the ID of the space +--- +--- Returns: +--- * true if the space change was initiated, or nil and an error message if there is an error trying to switch spaces. +--- +--- Notes: +--- * This function changes to a space by opening up the Mission Control display and then programmatically invoking the button to activate the space. This is unavoidable. You can minimize, but not entirely remove, the visual shift to the Mission Control display by by enabling "Reduce motion" in System Preferences -> Accessibility -> Display. +--- * The action of changing to a new space automatically closes the Mission Control display, so unlike ([hs.spaces.missionControlSpaceNames](#missionControlSpaceNames), [hs.spaces.addSpaceToScreen](#addSpaceToScreen), and [hs.spaces.removeSpace](#removeSpace), there is no flag you can specify to leave Mission Control visible. When possible, you should generally invoke this function last if you are performing multiple actions and want to minimize the amount of time the Mission Control display is visible and reduce the visual side affects. +--- * The Accessibility elements required to change to a space are not created until the Mission Control display is fully visible. Because of this, there is a built in delay when invoking this function that can be adjusted by changing the value of [hs.spaces.MCwaitTime](#MCwaitTime). +module.gotoSpace = function(...) + local args = { ... } + assert(#args == 1, "expected 1 argument") + local spaceID = args[1] + assert(math.type(spaceID) == "integer", "space id must be an integer") + + local screenUUID, screenID = module.spaceDisplay(spaceID), nil + if not screenUUID then + return nil, "space not found in managed displays" + end + for _, vScreen in ipairs(screen.allScreens()) do + if screenUUID == vScreen:getUUID() then + screenID = vScreen:id() + break + end + end + + local count + for i, vSpace in ipairs(module.spacesForScreen(screenUUID)) do + if spaceID == vSpace then + count = i + break + end + end + + openMissionControl() + local mcSpacesList, errMsg = findSpacesSubgroup("mc.spaces.list", screenID) + if not mcSpacesList then + closeMissionControl() + return nil, errMsg + end + + -- delay to make sure Mission Control has stabilized + waitForMissionControl() + + local child = mcSpacesList[count] + local status, errMsg2 = child:performAction("AXPress") + if status then + return true + else + closeMissionControl() + return nil, errMsg2 + end +end + + +--- hs.spaces.removeSpace(spaceID, [closeMC]) -> true | nil, errMsg +--- Function +--- Removes the specified space. +--- +--- Parameters: +--- * `spaceID` - an integer specifying the ID of the space +--- * `closeMC` - an optional boolean, default true, specifying whether or not the Mission Control display should be closed after removing the space. +--- +--- Returns: +--- * true if the space removal was initiated, or nil and an error message if there is an error trying to remove the space. +--- +--- Notes: +--- * You cannot remove a currently active space -- move to another one first with [hs.spaces.gotoSpace](#gotoSpace). +--- * If a screen has only one user space (i.e. not a full screen application window or tiled set), it cannot be removed. +--- * This function removes a space by opening up the Mission Control display and then programmatically invoking the button to remove the specified space. This is unavoidable. You can minimize, but not entirely remove, the visual shift to the Mission Control display by by enabling "Reduce motion" in System Preferences -> Accessibility -> Display. +--- * If you intend to perform multiple actions which require the Mission Control display (([hs.spaces.missionControlSpaceNames](#missionControlSpaceNames), [hs.spaces.addSpaceToScreen](#addSpaceToScreen), [hs.spaces.removeSpace](#removeSpace), or [hs.spaces.gotoSpace](#gotoSpace)), you can pass in `false` as the final argument to prevent the automatic closure of the Mission Control display -- this will reduce the visual side-affects to one transition instead of many. +--- * The Accessibility elements required to change to a space are not created until the Mission Control display is fully visible. Because of this, there is a built in delay when invoking this function that can be adjusted by changing the value of [hs.spaces.MCwaitTime](#MCwaitTime). +module.removeSpace = function(...) + local args, closeMC = { ... }, true + assert(#args > 0 and #args < 3, "expected between 1 and 2 arguments") + local spaceID = args[1] + if #args > 1 then closeMC = args[2] end + + assert(type(closeMC) == "boolean", "close flag must be boolean") + assert(math.type(spaceID) == "integer", "space id must be an integer") + + local screenUUID, screenID = module.spaceDisplay(spaceID), nil + if not screenUUID then + return nil, "space not found in managed displays" + end + for _, vScreen in ipairs(screen.allScreens()) do + if screenUUID == vScreen:getUUID() then + screenID = vScreen:id() + break + end + end + + local spacesOnScreen = module.spacesForScreen(screenUUID) + if module.spaceType(spaceID) == "user" then + local userCount = 0 + for _, vSpace in ipairs(spacesOnScreen) do + if module.spaceType(vSpace) == "user" then userCount = userCount + 1 end + end + if userCount == 1 then + return nil, "unable to remove the only user space on a screen" + end + + if module.activeSpaceOnScreen(screenID) == spaceID then + return nil, "cannot remove a currently active user space" + end + end + + local count + for i, vSpace in ipairs(spacesOnScreen) do + if spaceID == vSpace then + count = i + break + end + end + + openMissionControl() + local mcSpacesList, errMsg = findSpacesSubgroup("mc.spaces.list", screenID) + if not mcSpacesList then + if closeMC then closeMissionControl() end + return nil, errMsg + end + + -- delay to make sure Mission Control has stabilized + waitForMissionControl() -local spaces = {} + local child = mcSpacesList[count] + local status, errMsg2 = child:performAction("AXRemoveDesktop") + if closeMC then closeMissionControl() end + if status then + return true + else + return nil, errMsg2 + end +end -spaces.watcher = require "hs.libspaceswatcher" +-- Return Module Object -------------------------------------------------- -return spaces +return setmetatable(module, { + __gc = function(_) + host.locale.unregisterCallback(localeChange_identifier) + end +})