From cc2cac4e341ffab2ea9cfddb34065cc775285cc3 Mon Sep 17 00:00:00 2001 From: Nikita Bobko Date: Sat, 16 Sep 2023 23:03:13 +0200 Subject: [PATCH] WIP Implement FocusCommand --- .gitignore | 2 +- AeroSpace.xcodeproj/project.pbxproj | 76 +++++++++++-------- build-debug.sh | 4 +- build-release.sh | 6 +- config-examples/default-config.toml | 6 +- config-examples/i3-like-config-example.toml | 7 +- src/AeroSpaceApp.swift | 22 +++--- src/{ViewModel.swift => TrayModel.swift} | 8 +- src/axWrappers/axObservers.swift | 11 --- .../CloseAllWindowsButCurrentCommand.swift | 6 ++ src/command/Command.swift | 1 + src/command/ExecAndForgetCommand.swift | 8 ++ ...Command.swift => ExecAndWaitCommand.swift} | 2 +- src/command/FocusCommand.swift | 45 ++++++++++- src/command/WorkspaceBackAndForth.swift | 6 ++ src/command/WorkspaceCommand.swift | 28 ++++++- src/command/parseCommand.swift | 69 +++++++++++++++++ src/config/Config.swift | 4 +- src/config/parseConfig.swift | 67 ++-------------- src/{axWrappers => model}/MacApp.swift | 0 src/{axWrappers => model}/MacWindow.swift | 25 ++++-- src/model/TilingContainer.swift | 4 +- src/model/TreeNode.swift | 17 +++-- src/model/TreeNodeEx.swift | 29 +++++-- src/model/Workspace.swift | 7 +- src/refresh.swift | 48 ++++-------- src/util/CollectionEx.swift | 4 + src/util/Direction.swift | 20 +++++ src/util/MruStack.swift | 73 ++++++++++++++++++ src/util/NSScreenEx.swift | 2 +- src/util/NSWorkspaceEx.swift | 2 +- src/util/Rect.swift | 4 + src/util/SequenceEx.swift | 13 ++++ src/{ => util}/accessibility.swift | 6 ++ src/util/utils.swift | 10 --- 35 files changed, 433 insertions(+), 209 deletions(-) rename src/{ViewModel.swift => TrayModel.swift} (57%) delete mode 100644 src/axWrappers/axObservers.swift create mode 100644 src/command/CloseAllWindowsButCurrentCommand.swift create mode 100644 src/command/ExecAndForgetCommand.swift rename src/command/{BashCommand.swift => ExecAndWaitCommand.swift} (93%) create mode 100644 src/command/WorkspaceBackAndForth.swift create mode 100644 src/command/parseCommand.swift rename src/{axWrappers => model}/MacApp.swift (100%) rename src/{axWrappers => model}/MacWindow.swift (89%) create mode 100644 src/util/Direction.swift create mode 100644 src/util/MruStack.swift rename src/{ => util}/accessibility.swift (98%) diff --git a/.gitignore b/.gitignore index 73308c99..d435b525 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /.idea -/build +/.build # XCode User settings xcuserdata/ diff --git a/AeroSpace.xcodeproj/project.pbxproj b/AeroSpace.xcodeproj/project.pbxproj index 1396faaa..c1438e6e 100644 --- a/AeroSpace.xcodeproj/project.pbxproj +++ b/AeroSpace.xcodeproj/project.pbxproj @@ -7,41 +7,46 @@ objects = { /* Begin PBXBuildFile section */ - 07FF7938628995F68DFEB524 /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F99E8C4FD17A1D939C41F1 /* ViewModel.swift */; }; 080DAA83BADA766D94A2BD3C /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A4C333EA5B4D4527DD97D4 /* Workspace.swift */; }; 0A90EEEAC020DD3A56736014 /* FocusCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = E761155C73F06E2CF5E292A4 /* FocusCommand.swift */; }; 115F5CA4BEB80B645E66D198 /* NSScreenEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF3BB3DD434C75536217CB88 /* NSScreenEx.swift */; }; + 18C28195ACBE96C8CAC5D45E /* WorkspaceBackAndForth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0624BD31CE77758353BA61 /* WorkspaceBackAndForth.swift */; }; 1C46EBB55D401C0D1AFD50F0 /* CollectionEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51CE37C1B8D858C81A396F40 /* CollectionEx.swift */; }; 1CB4082BE5C95CA8CD52BED9 /* Maybe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 345148B22F8A8F85109229AE /* Maybe.swift */; }; 1D408CDF1A489E527327EB15 /* CompositeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82CD9670B7A6050073E0F76 /* CompositeCommand.swift */; }; 238EF26CAAADD1FE11312D7C /* default-config.toml in Resources */ = {isa = PBXBuildFile; fileRef = 8FE45A887100EB70912B07F0 /* default-config.toml */; }; 29AC28A25E1A66C608EBD7ED /* WorkspaceCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DD32B1711B8EFCC834B68E /* WorkspaceCommand.swift */; }; - 324F5C501D2BD0D36590007B /* BashCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E2A2BBD8A32FFCD6A88BC7 /* BashCommand.swift */; }; + 2E06134604F2510189F1FA85 /* ExecAndWaitCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9EDB8770FBA8B2E6FE91BBB /* ExecAndWaitCommand.swift */; }; 4005ECE237BD9230F74CA917 /* TreeNodeEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C39B0C4E4888832129C4C7 /* TreeNodeEx.swift */; }; + 42197B9C71A0CDDE65804A6A /* accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D31BF26EAFA96F675D2C14B /* accessibility.swift */; }; 45AA5FD4A023AF751922BC22 /* BundleEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7A2DF0D1F72B80B1F04240 /* BundleEx.swift */; }; 45EA2D1C90430C432E123B51 /* keysMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0D40CBD65704BA9595C2FA /* keysMap.swift */; }; - 518B9E5AC031C24C7C84CD70 /* MacWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243F29F496F7463F3482DD10 /* MacWindow.swift */; }; 5DA2DA21600E8B5BCA3DCFC0 /* LayoutCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B673C67B00DBFCC27FFE7 /* LayoutCommand.swift */; }; 6317AB471F4C4F5D66A25784 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EEDBFFCA7A77D96B18FB0732 /* Assets.xcassets */; }; + 635733FDDF37E44364372B74 /* MruStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954A434EE57D76F5A9D4140D /* MruStack.swift */; }; 64A058E536F1EEF7F01043AF /* TOMLKit in Frameworks */ = {isa = PBXBuildFile; productRef = EC8E4F2CA4FF8884F9F59975 /* TOMLKit */; }; 66E6CDA75DDD5E4B9647EDE2 /* AeroSpaceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E81623E8954701269A22322 /* AeroSpaceApp.swift */; }; 6715CC184E718828173DFBD5 /* ConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12EA50A972BA714F2BA3BBC1 /* ConfigTest.swift */; }; 6820E6846AE51B6988B6F673 /* utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD02433B4415EEB163074CE5 /* utils.swift */; }; 6E4E235FDA41307B19F16182 /* ModeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0CD3C2A0E86CDB9DF312AB /* ModeCommand.swift */; }; + 77FA83225024151CD556E1ED /* CloseAllWindowsButCurrentCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99853C505D93E41F6531C324 /* CloseAllWindowsButCurrentCommand.swift */; }; 783B0B965BA45D7A2943F7BF /* TilingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E05FB0C7158C8B6DECBD603 /* TilingContainer.swift */; }; 78EE0CEF814ABDBA67941B84 /* Rect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B788A95DD3C267878E05B5 /* Rect.swift */; }; - 7FE92DDAC2F094C83A177914 /* MacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6296D5F9AFE5F266EE4B1D0 /* MacApp.swift */; }; 852F88894A3B9FC385563665 /* HotKey in Frameworks */ = {isa = PBXBuildFile; productRef = 42BC1E757EF69233C2262FF4 /* HotKey */; }; 920FDF8498DCCB62149D1719 /* Monitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6507EBAA795220FD0C05384 /* Monitor.swift */; }; - 96593DF93A69CA2E05189A3F /* axObservers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E6F3930E3BF5D8196A20E9B /* axObservers.swift */; }; A0765C31043BCFB0420BF1C9 /* parseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67DBAF4ECF8A0B931FC34EAD /* parseConfig.swift */; }; A2CBF9674964F9083BB198D2 /* ArrayEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883D7F7F87FBE7D0BDE4E87F /* ArrayEx.swift */; }; A4F66097ADF0FD58C6B715AE /* NSWorkspaceEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6935AF0A2DB3D186D1C6218F /* NSWorkspaceEx.swift */; }; + A5BFF75CF8021A585BC1F9D5 /* parseCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB03A4736BC3F6D19E4E69F3 /* parseCommand.swift */; }; AE76A183D0454E4C8ADCE380 /* SequenceEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE5DCAEC5EE619CE33859E7 /* SequenceEx.swift */; }; B0D0C37BAE7E7F0D0FF1E9FC /* GlobalObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C2E5977331398421A4FC168 /* GlobalObserver.swift */; }; B1E2002BB8F70F2555AAA82D /* TreeNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D295CA45172ADBDB1E4DF708 /* TreeNode.swift */; }; B3702BB393A9B03CCAE4C60E /* refresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 526B113159987FA43EA41120 /* refresh.swift */; }; - E2FD8E2B2D2BE6B88BF8E8AD /* accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE605CF46DE6377C69B9D49D /* accessibility.swift */; }; + BC6511DA2ABE84164D90C181 /* ExecAndForgetCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569422C0C4C23EF3E024C8E6 /* ExecAndForgetCommand.swift */; }; + C64CF89DF8BA0BACAE74ABA5 /* MacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F81DBBF5E3DC112069919013 /* MacApp.swift */; }; + C6C7B2520B940E3D028B334A /* Direction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6094492F5D46B22BAFA3741F /* Direction.swift */; }; + CA00DF404DB49E4B14595CD3 /* MacWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC6257BAD50661E81280963 /* MacWindow.swift */; }; + D941E6DBC319F08676B40E5B /* TrayModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A8D2390FEF4421BBCA411C6 /* TrayModel.swift */; }; EDFDE707B4DC5E500B1709B1 /* MoveThroughCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B9FFF81EB0327ABD51A7FE /* MoveThroughCommand.swift */; }; F74FC5ECBCE9C8A6D09AE9F5 /* util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7910D7B9C0A232187CCA1F10 /* util.swift */; }; F982DB924450BBBB4FDF4C2C /* Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD90FB41FD41602120F67FF5 /* Command.swift */; }; @@ -65,43 +70,48 @@ 1A2B673C67B00DBFCC27FFE7 /* LayoutCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutCommand.swift; sourceTree = ""; }; 1C0D40CBD65704BA9595C2FA /* keysMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = keysMap.swift; sourceTree = ""; }; 1E81623E8954701269A22322 /* AeroSpaceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AeroSpaceApp.swift; sourceTree = ""; }; - 243F29F496F7463F3482DD10 /* MacWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacWindow.swift; sourceTree = ""; }; - 24F99E8C4FD17A1D939C41F1 /* ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModel.swift; sourceTree = ""; }; 28B788A95DD3C267878E05B5 /* Rect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Rect.swift; sourceTree = ""; }; + 2F0624BD31CE77758353BA61 /* WorkspaceBackAndForth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceBackAndForth.swift; sourceTree = ""; }; 345148B22F8A8F85109229AE /* Maybe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Maybe.swift; sourceTree = ""; }; 38A4C333EA5B4D4527DD97D4 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; 3C2E5977331398421A4FC168 /* GlobalObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalObserver.swift; sourceTree = ""; }; 3E05FB0C7158C8B6DECBD603 /* TilingContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TilingContainer.swift; sourceTree = ""; }; 43DD32B1711B8EFCC834B68E /* WorkspaceCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCommand.swift; sourceTree = ""; }; + 4A8D2390FEF4421BBCA411C6 /* TrayModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrayModel.swift; sourceTree = ""; }; 4B0CD3C2A0E86CDB9DF312AB /* ModeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeCommand.swift; sourceTree = ""; }; + 4CC6257BAD50661E81280963 /* MacWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacWindow.swift; sourceTree = ""; }; 51CE37C1B8D858C81A396F40 /* CollectionEx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionEx.swift; sourceTree = ""; }; 526B113159987FA43EA41120 /* refresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = refresh.swift; sourceTree = ""; }; 5274C575044C2A7123C57584 /* AeroSpace-Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "AeroSpace-Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 55E2A2BBD8A32FFCD6A88BC7 /* BashCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BashCommand.swift; sourceTree = ""; }; + 569422C0C4C23EF3E024C8E6 /* ExecAndForgetCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecAndForgetCommand.swift; sourceTree = ""; }; + 6094492F5D46B22BAFA3741F /* Direction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Direction.swift; sourceTree = ""; }; 67B9FFF81EB0327ABD51A7FE /* MoveThroughCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveThroughCommand.swift; sourceTree = ""; }; 67DBAF4ECF8A0B931FC34EAD /* parseConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = parseConfig.swift; sourceTree = ""; }; 6935AF0A2DB3D186D1C6218F /* NSWorkspaceEx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWorkspaceEx.swift; sourceTree = ""; }; 7910D7B9C0A232187CCA1F10 /* util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = util.swift; sourceTree = ""; }; - 7E6F3930E3BF5D8196A20E9B /* axObservers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = axObservers.swift; sourceTree = ""; }; 883D7F7F87FBE7D0BDE4E87F /* ArrayEx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayEx.swift; sourceTree = ""; }; 8B7A2DF0D1F72B80B1F04240 /* BundleEx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleEx.swift; sourceTree = ""; }; 8FE45A887100EB70912B07F0 /* default-config.toml */ = {isa = PBXFileReference; path = "default-config.toml"; sourceTree = ""; }; + 954A434EE57D76F5A9D4140D /* MruStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MruStack.swift; sourceTree = ""; }; 9752080BBA547C2A0EF076F0 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + 99853C505D93E41F6531C324 /* CloseAllWindowsButCurrentCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseAllWindowsButCurrentCommand.swift; sourceTree = ""; }; + 9D31BF26EAFA96F675D2C14B /* accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = accessibility.swift; sourceTree = ""; }; AAE5DCAEC5EE619CE33859E7 /* SequenceEx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceEx.swift; sourceTree = ""; }; AF3BB3DD434C75536217CB88 /* NSScreenEx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSScreenEx.swift; sourceTree = ""; }; BD02433B4415EEB163074CE5 /* utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = utils.swift; sourceTree = ""; }; BEF353340822CD20E9DAB3EC /* AeroSpace.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AeroSpace.entitlements; sourceTree = ""; }; + CB03A4736BC3F6D19E4E69F3 /* parseCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = parseCommand.swift; sourceTree = ""; }; CD90FB41FD41602120F67FF5 /* Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Command.swift; sourceTree = ""; }; CF3C9038C846369FDD71D1D2 /* ReloadConfigCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReloadConfigCommand.swift; sourceTree = ""; }; D295CA45172ADBDB1E4DF708 /* TreeNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeNode.swift; sourceTree = ""; }; - D6296D5F9AFE5F266EE4B1D0 /* MacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacApp.swift; sourceTree = ""; }; D82CD9670B7A6050073E0F76 /* CompositeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositeCommand.swift; sourceTree = ""; }; E761155C73F06E2CF5E292A4 /* FocusCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCommand.swift; sourceTree = ""; }; EC2F56249A233EC9806D0F08 /* Bridged-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Bridged-Header.h"; sourceTree = ""; }; - EE605CF46DE6377C69B9D49D /* accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = accessibility.swift; sourceTree = ""; }; EEDBFFCA7A77D96B18FB0732 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; F6507EBAA795220FD0C05384 /* Monitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Monitor.swift; sourceTree = ""; }; + F81DBBF5E3DC112069919013 /* MacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacApp.swift; sourceTree = ""; }; F8C39B0C4E4888832129C4C7 /* TreeNodeEx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeNodeEx.swift; sourceTree = ""; }; + F9EDB8770FBA8B2E6FE91BBB /* ExecAndWaitCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecAndWaitCommand.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -128,6 +138,8 @@ 38E5F06C862662755065FF1D /* model */ = { isa = PBXGroup; children = ( + F81DBBF5E3DC112069919013 /* MacApp.swift */, + 4CC6257BAD50661E81280963 /* MacWindow.swift */, 3E05FB0C7158C8B6DECBD603 /* TilingContainer.swift */, D295CA45172ADBDB1E4DF708 /* TreeNode.swift */, F8C39B0C4E4888832129C4C7 /* TreeNodeEx.swift */, @@ -168,15 +180,13 @@ 8338180CE208CBDCD6D8E911 /* src */ = { isa = PBXGroup; children = ( - EE605CF46DE6377C69B9D49D /* accessibility.swift */, BEF353340822CD20E9DAB3EC /* AeroSpace.entitlements */, 1E81623E8954701269A22322 /* AeroSpaceApp.swift */, EEDBFFCA7A77D96B18FB0732 /* Assets.xcassets */, EC2F56249A233EC9806D0F08 /* Bridged-Header.h */, 3C2E5977331398421A4FC168 /* GlobalObserver.swift */, 526B113159987FA43EA41120 /* refresh.swift */, - 24F99E8C4FD17A1D939C41F1 /* ViewModel.swift */, - 89B36C927EF9F6CB9CE830C7 /* axWrappers */, + 4A8D2390FEF4421BBCA411C6 /* TrayModel.swift */, A711A25EEC57098295B90C17 /* command */, 50A41C4CCFC95C514CA1EAD5 /* config */, 38E5F06C862662755065FF1D /* model */, @@ -185,16 +195,6 @@ path = src; sourceTree = ""; }; - 89B36C927EF9F6CB9CE830C7 /* axWrappers */ = { - isa = PBXGroup; - children = ( - 7E6F3930E3BF5D8196A20E9B /* axObservers.swift */, - D6296D5F9AFE5F266EE4B1D0 /* MacApp.swift */, - 243F29F496F7463F3482DD10 /* MacWindow.swift */, - ); - path = axWrappers; - sourceTree = ""; - }; A6E1E25AB6F33B13B0633B9C /* test */ = { isa = PBXGroup; children = ( @@ -207,14 +207,18 @@ A711A25EEC57098295B90C17 /* command */ = { isa = PBXGroup; children = ( - 55E2A2BBD8A32FFCD6A88BC7 /* BashCommand.swift */, + 99853C505D93E41F6531C324 /* CloseAllWindowsButCurrentCommand.swift */, CD90FB41FD41602120F67FF5 /* Command.swift */, D82CD9670B7A6050073E0F76 /* CompositeCommand.swift */, + 569422C0C4C23EF3E024C8E6 /* ExecAndForgetCommand.swift */, + F9EDB8770FBA8B2E6FE91BBB /* ExecAndWaitCommand.swift */, E761155C73F06E2CF5E292A4 /* FocusCommand.swift */, 1A2B673C67B00DBFCC27FFE7 /* LayoutCommand.swift */, 4B0CD3C2A0E86CDB9DF312AB /* ModeCommand.swift */, 67B9FFF81EB0327ABD51A7FE /* MoveThroughCommand.swift */, + CB03A4736BC3F6D19E4E69F3 /* parseCommand.swift */, CF3C9038C846369FDD71D1D2 /* ReloadConfigCommand.swift */, + 2F0624BD31CE77758353BA61 /* WorkspaceBackAndForth.swift */, 43DD32B1711B8EFCC834B68E /* WorkspaceCommand.swift */, ); path = command; @@ -223,11 +227,14 @@ C136937AA077E63558E9707C /* util */ = { isa = PBXGroup; children = ( + 9D31BF26EAFA96F675D2C14B /* accessibility.swift */, 883D7F7F87FBE7D0BDE4E87F /* ArrayEx.swift */, 8B7A2DF0D1F72B80B1F04240 /* BundleEx.swift */, 51CE37C1B8D858C81A396F40 /* CollectionEx.swift */, + 6094492F5D46B22BAFA3741F /* Direction.swift */, 345148B22F8A8F85109229AE /* Maybe.swift */, F6507EBAA795220FD0C05384 /* Monitor.swift */, + 954A434EE57D76F5A9D4140D /* MruStack.swift */, AF3BB3DD434C75536217CB88 /* NSScreenEx.swift */, 6935AF0A2DB3D186D1C6218F /* NSWorkspaceEx.swift */, 28B788A95DD3C267878E05B5 /* Rect.swift */, @@ -341,35 +348,40 @@ files = ( 66E6CDA75DDD5E4B9647EDE2 /* AeroSpaceApp.swift in Sources */, A2CBF9674964F9083BB198D2 /* ArrayEx.swift in Sources */, - 324F5C501D2BD0D36590007B /* BashCommand.swift in Sources */, 45AA5FD4A023AF751922BC22 /* BundleEx.swift in Sources */, + 77FA83225024151CD556E1ED /* CloseAllWindowsButCurrentCommand.swift in Sources */, 1C46EBB55D401C0D1AFD50F0 /* CollectionEx.swift in Sources */, F982DB924450BBBB4FDF4C2C /* Command.swift in Sources */, 1D408CDF1A489E527327EB15 /* CompositeCommand.swift in Sources */, FD4386BC632BAA6A4105FFD8 /* Config.swift in Sources */, + C6C7B2520B940E3D028B334A /* Direction.swift in Sources */, + BC6511DA2ABE84164D90C181 /* ExecAndForgetCommand.swift in Sources */, + 2E06134604F2510189F1FA85 /* ExecAndWaitCommand.swift in Sources */, 0A90EEEAC020DD3A56736014 /* FocusCommand.swift in Sources */, B0D0C37BAE7E7F0D0FF1E9FC /* GlobalObserver.swift in Sources */, 5DA2DA21600E8B5BCA3DCFC0 /* LayoutCommand.swift in Sources */, - 7FE92DDAC2F094C83A177914 /* MacApp.swift in Sources */, - 518B9E5AC031C24C7C84CD70 /* MacWindow.swift in Sources */, + C64CF89DF8BA0BACAE74ABA5 /* MacApp.swift in Sources */, + CA00DF404DB49E4B14595CD3 /* MacWindow.swift in Sources */, 1CB4082BE5C95CA8CD52BED9 /* Maybe.swift in Sources */, 6E4E235FDA41307B19F16182 /* ModeCommand.swift in Sources */, 920FDF8498DCCB62149D1719 /* Monitor.swift in Sources */, EDFDE707B4DC5E500B1709B1 /* MoveThroughCommand.swift in Sources */, + 635733FDDF37E44364372B74 /* MruStack.swift in Sources */, 115F5CA4BEB80B645E66D198 /* NSScreenEx.swift in Sources */, A4F66097ADF0FD58C6B715AE /* NSWorkspaceEx.swift in Sources */, 78EE0CEF814ABDBA67941B84 /* Rect.swift in Sources */, FC35D6D0A678CC802972C6FE /* ReloadConfigCommand.swift in Sources */, AE76A183D0454E4C8ADCE380 /* SequenceEx.swift in Sources */, 783B0B965BA45D7A2943F7BF /* TilingContainer.swift in Sources */, + D941E6DBC319F08676B40E5B /* TrayModel.swift in Sources */, B1E2002BB8F70F2555AAA82D /* TreeNode.swift in Sources */, 4005ECE237BD9230F74CA917 /* TreeNodeEx.swift in Sources */, - 07FF7938628995F68DFEB524 /* ViewModel.swift in Sources */, 080DAA83BADA766D94A2BD3C /* Workspace.swift in Sources */, + 18C28195ACBE96C8CAC5D45E /* WorkspaceBackAndForth.swift in Sources */, 29AC28A25E1A66C608EBD7ED /* WorkspaceCommand.swift in Sources */, - E2FD8E2B2D2BE6B88BF8E8AD /* accessibility.swift in Sources */, - 96593DF93A69CA2E05189A3F /* axObservers.swift in Sources */, + 42197B9C71A0CDDE65804A6A /* accessibility.swift in Sources */, 45EA2D1C90430C432E123B51 /* keysMap.swift in Sources */, + A5BFF75CF8021A585BC1F9D5 /* parseCommand.swift in Sources */, A0765C31043BCFB0420BF1C9 /* parseConfig.swift in Sources */, B3702BB393A9B03CCAE4C60E /* refresh.swift in Sources */, 6820E6846AE51B6988B6F673 /* utils.swift in Sources */, diff --git a/build-debug.sh b/build-debug.sh index 8b26f7ce..702de77e 100755 --- a/build-debug.sh +++ b/build-debug.sh @@ -9,7 +9,7 @@ cd "$(dirname "$0")" xcodegen # https://github.com/yonaskolb/XcodeGen xcodebuild -scheme AeroSpace build -configuration Debug # no clean because it may lead to accessibility permission loss -rm -rf build && mkdir build +rm -rf .build && mkdir .build pushd ~/Library/Developer/Xcode/DerivedData > /dev/null if [ "$(ls | grep AeroSpace | wc -l)" -ne 1 ]; then echo "Found several AeroSpace dirs in $(pwd)" @@ -17,4 +17,4 @@ pushd ~/Library/Developer/Xcode/DerivedData > /dev/null exit 1 fi popd > /dev/null -cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Debug/AeroSpace-Debug.app build +cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Debug/AeroSpace-Debug.app .build diff --git a/build-release.sh b/build-release.sh index fac32f3b..8a1ecc9d 100755 --- a/build-release.sh +++ b/build-release.sh @@ -9,7 +9,7 @@ cd "$(dirname "$0")" xcodegen # https://github.com/yonaskolb/XcodeGen xcodebuild -scheme AeroSpace build -configuration Release -rm -rf build && mkdir build +rm -rf .build && mkdir .build pushd ~/Library/Developer/Xcode/DerivedData > /dev/null if [ "$(ls | grep AeroSpace | wc -l)" -ne 1 ]; then echo "Found several AeroSpace dirs in $(pwd)" @@ -17,8 +17,8 @@ pushd ~/Library/Developer/Xcode/DerivedData > /dev/null exit 1 fi popd > /dev/null -cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Release/AeroSpace.app build +cp -r ~/Library/Developer/Xcode/DerivedData/AeroSpace*/Build/Products/Release/AeroSpace.app .build -pushd build +pushd .build zip -r AeroSpace.zip AeroSpace.app popd diff --git a/config-examples/default-config.toml b/config-examples/default-config.toml index 9d7f26fe..c0eb23e3 100644 --- a/config-examples/default-config.toml +++ b/config-examples/default-config.toml @@ -10,7 +10,7 @@ auto-flatten-containers = true floating-windows-on-top = true [mode.main.binding] -alt-enter = 'bash /usr/bin/open /System/Applications/Utilities/Terminal.app' +alt-enter = 'exec_and_forget open /System/Applications/Utilities/Terminal.app' alt-shift-quote = 'focus child' alt-quote = 'focus parent' @@ -25,6 +25,8 @@ alt-j = 'focus down' alt-k = 'focus up' alt-l = 'focus right' +alt-backslash = 'close_all_windows_but_current' + # Move window alt-shift-k = 'move_through up' @@ -104,7 +106,7 @@ alt-z = 'workspace ZZZ' # alt-shift-y = 'move container to workspace YYY' # alt-shift-z = 'move container to workspace ZZZ' -# alt-tab = 'workspace_back_and_forth' +alt-tab = 'workspace_back_and_forth' # alt-shift-slash.alt-shift-k = 'move_in up' # alt-shift-slash.alt-shift-h = 'move_in left' diff --git a/config-examples/i3-like-config-example.toml b/config-examples/i3-like-config-example.toml index dc6a88e5..a7b9ebe3 100644 --- a/config-examples/i3-like-config-example.toml +++ b/config-examples/i3-like-config-example.toml @@ -5,7 +5,7 @@ auto-flatten-containers = false [mode.main.binding] -alt-enter = 'bash /usr/bin/open /System/Applications/Utilities/Terminal.app' +alt-enter = 'exec_and_forget open /System/Applications/Utilities/Terminal.app' alt-h = 'focus left' alt-j = 'focus down' @@ -29,7 +29,10 @@ alt-shift-l = 'move_through right' #todo support parsing #alt-shift-space = 'layout floating tiling' # 'floating toggle' in i3 -alt-space = 'focus toggle_tiling_floating' + +# Not supported, because this command is redundant in AeroSpace mental model. +# Floating windows are part of the tree from the perspective of 'focus' command. +#alt-space = 'focus toggle_tiling_floating' alt-a = 'focus parent' diff --git a/src/AeroSpaceApp.swift b/src/AeroSpaceApp.swift index bf2c014f..dbfec8d5 100644 --- a/src/AeroSpaceApp.swift +++ b/src/AeroSpaceApp.swift @@ -25,21 +25,17 @@ struct Setting { @main struct AeroSpaceApp: App { var hotKeys: [HotKey] = [] // Keep hotkeys in memory - @StateObject var viewModel = ViewModel.shared + @StateObject var viewModel = TrayModel.shared init() { - reloadConfig() - - //checkAccessibilityPermissions() - //GlobalObserver.initObserver() - //for setting in settings { - // hotKeys.append(HotKey(key: setting.hotkey, modifiers: setting.modifiers, keyUpHandler: { - // switchToWorkspace(Workspace.get(byName: setting.name)) - // })) - //} - //refresh() - //test() + if NSClassFromString("XCTestCase") == nil { // Prevent SwiftUI app loading during unit testing + reloadConfig() + checkAccessibilityPermissions() + GlobalObserver.initObserver() + config.mainMode.activate() + refresh() + } } var body: some Scene { @@ -49,7 +45,7 @@ struct AeroSpaceApp: App { Text("Workspaces:") ForEach(Workspace.all) { workspace in Button { - switchToWorkspace(workspace) + WorkspaceCommand.switchToWorkspace(workspace) } label: { Toggle(isOn: workspace.name == viewModel.focusedWorkspaceTrayText ? Binding(get: { true }, set: { _, _ in }) diff --git a/src/ViewModel.swift b/src/TrayModel.swift similarity index 57% rename from src/ViewModel.swift rename to src/TrayModel.swift index 11d45f64..2bcda316 100644 --- a/src/ViewModel.swift +++ b/src/TrayModel.swift @@ -1,11 +1,9 @@ import Foundation -class ViewModel: ObservableObject { - static let shared = ViewModel() +class TrayModel: ObservableObject { + static let shared = TrayModel() - private init() { - } + private init() {} @Published var focusedWorkspaceTrayText: String = currentEmptyWorkspace.name // config.first?.name ?? "W: 1" } - diff --git a/src/axWrappers/axObservers.swift b/src/axWrappers/axObservers.swift deleted file mode 100644 index 607ec7a2..00000000 --- a/src/axWrappers/axObservers.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -struct AxObserverWrapper { - let obs: AXObserver - let ax: AXUIElement - let notif: CFString -} - -func refreshObs(_ obs: AXObserver, ax: AXUIElement, notif: CFString, data: UnsafeMutableRawPointer?) { - refresh() -} diff --git a/src/command/CloseAllWindowsButCurrentCommand.swift b/src/command/CloseAllWindowsButCurrentCommand.swift new file mode 100644 index 00000000..c5b6aaef --- /dev/null +++ b/src/command/CloseAllWindowsButCurrentCommand.swift @@ -0,0 +1,6 @@ +class CloseAllWindowsButCurrentCommand: Command { + func run() { + precondition(Thread.current.isMainThread) + // todo + } +} \ No newline at end of file diff --git a/src/command/Command.swift b/src/command/Command.swift index fc97f350..b6a733d8 100644 --- a/src/command/Command.swift +++ b/src/command/Command.swift @@ -1,3 +1,4 @@ protocol Command { + @MainActor func run() async } diff --git a/src/command/ExecAndForgetCommand.swift b/src/command/ExecAndForgetCommand.swift new file mode 100644 index 00000000..ae32c53c --- /dev/null +++ b/src/command/ExecAndForgetCommand.swift @@ -0,0 +1,8 @@ +struct ExecAndForgetCommand: Command { + let bashCommand: String + + func run() async { + precondition(Thread.current.isMainThread) + try! Process.run(URL(filePath: "/bin/bash"), arguments: ["-c", bashCommand]) + } +} diff --git a/src/command/BashCommand.swift b/src/command/ExecAndWaitCommand.swift similarity index 93% rename from src/command/BashCommand.swift rename to src/command/ExecAndWaitCommand.swift index f1511c19..09c3a52e 100644 --- a/src/command/BashCommand.swift +++ b/src/command/ExecAndWaitCommand.swift @@ -1,4 +1,4 @@ -struct BashCommand: Command { +struct ExecAndWaitCommand: Command { let bashCommand: String func run() async { diff --git a/src/command/FocusCommand.swift b/src/command/FocusCommand.swift index 25d999df..ff3aafc1 100644 --- a/src/command/FocusCommand.swift +++ b/src/command/FocusCommand.swift @@ -1,14 +1,51 @@ struct FocusCommand: Command { - let direction: Direction + let direction: FDirection - enum Direction: String { + enum FDirection: String { case up, down, left, right - case parent, child, floating, tiling, toggle_tiling_floating + case parent, child //, floating, tiling, toggle_tiling_floating // not needed + + // todo support only if asked + //case next, prev } func run() async { precondition(Thread.current.isMainThread) - // todo + guard let window = NSWorkspace.focusedApp?.macApp?.focusedWindow else { return } + if let direction = direction.direction { + let orientation = direction.orientation + guard let topMostChild = window.parentsWithSelf.lazy.first(where: { + $0.parent is Workspace || ($0.parent as? TilingContainer)?.orientation == orientation + }) else { return } + guard let parent = topMostChild.parent as? TilingContainer else { return } + guard let index = parent.children.firstIndex(of: topMostChild) else { return } + let mruIndexMap = window.workspace.mruWindows.mruIndexMap + let window: MacWindow? = parent.children.getOrNil(atIndex: direction.isPositive ? index + 1 : index - 1)? + .allLeafWindowsRecursive(snappedTo: direction.opposite) + .minBy { mruIndexMap[$0] ?? Int.max } + window?.focus() + } else { + // todo direction == .child || direction == .parent + } } } + +extension FocusCommand.FDirection { + var direction: Direction? { + switch self { + case .up: + return .up + case .down: + return .down + case .left: + return .left + case .right: + return .right + case .parent: + return nil + case .child: + return nil + } + } +} \ No newline at end of file diff --git a/src/command/WorkspaceBackAndForth.swift b/src/command/WorkspaceBackAndForth.swift new file mode 100644 index 00000000..ba601bb8 --- /dev/null +++ b/src/command/WorkspaceBackAndForth.swift @@ -0,0 +1,6 @@ +struct WorkspaceBackAndForth: Command { + func run() async { + precondition(Thread.current.isMainThread) + // todo + } +} diff --git a/src/command/WorkspaceCommand.swift b/src/command/WorkspaceCommand.swift index 235d6804..82526327 100644 --- a/src/command/WorkspaceCommand.swift +++ b/src/command/WorkspaceCommand.swift @@ -3,6 +3,32 @@ struct WorkspaceCommand : Command { func run() async { precondition(Thread.current.isMainThread) - switchToWorkspace(Workspace.get(byName: workspaceName)) + WorkspaceCommand.switchToWorkspace(Workspace.get(byName: workspaceName)) + } + + static func switchToWorkspace(_ workspace: Workspace) { + debug("Switch to workspace: \(workspace.name)") + refresh(endSession: false) + if let window = workspace.mruWindows.mru ?? workspace.anyLeafWindowRecursive { // switch to not empty workspace + window.focus() + // The switching itself will be done by refreshWorkspaces and layoutWorkspaces later in refresh + } else { // switch to empty workspace + precondition(workspace.isEffectivelyEmpty) + // It's the only place in the app where I allow myself to use NSScreen.main. + // This function isn't invoked from callbacks that's why .main should be fine + if let focusedMonitor = NSScreen.focusedMonitorOrNilIfDesktop ?? NSScreen.main?.monitor { + focusedMonitor.setActiveWorkspace(workspace) + } + defocusAllWindows() + } + refresh(startSession: false) + debug("End switch to workspace: \(workspace.name)") + } + + private static func defocusAllWindows() { + // Since AeroSpace doesn't show any windows, focusing AeroSpace defocuses all windows + let current = NSRunningApplication.current + current.activate(options: .activateIgnoringOtherApps) + NSWorkspace.focusedApp = current } } diff --git a/src/command/parseCommand.swift b/src/command/parseCommand.swift new file mode 100644 index 00000000..13859bdf --- /dev/null +++ b/src/command/parseCommand.swift @@ -0,0 +1,69 @@ +import TOMLKit + +// todo drop TomlBacktrace +func parseCommand(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> Command { + if let rawString = raw.string { + return parseSingleCommand(rawString, backtrace) + } else if let rawArray = raw.array { + let commands: [Command] = (0.. Command { + let words = raw.split(separator: " ") + let args = words[1...] + let firstWord = String(words.first ?? "") + if firstWord == "workspace" { + return WorkspaceCommand(workspaceName: parseSingleArg(args, firstWord, backtrace)) + } else if firstWord == "mode" { + return ModeCommand(idToActivate: parseSingleArg(args, firstWord, backtrace)) + } else if firstWord == "exec_and_wait" { + return ExecAndWaitCommand(bashCommand: raw.removePrefix(firstWord)) + } else if firstWord == "exec_and_forget" { + return ExecAndForgetCommand(bashCommand: raw.removePrefix(firstWord)) + } else if firstWord == "focus" { + let direction = FocusCommand.FDirection(rawValue: parseSingleArg(args, firstWord, backtrace)) + ?? errorT("\(backtrace): Can't parse '\(firstWord)' direction") + return FocusCommand(direction: direction) + } else if firstWord == "move_through" { + let direction = MoveThroughCommand.Direction(rawValue: parseSingleArg(args, firstWord, backtrace)) + ?? errorT("\(backtrace): Can't parse '\(firstWord)' direction") + return MoveThroughCommand(direction: direction) + } else if raw == "workspace_back_and_forth" { + return WorkspaceBackAndForth() + } else if raw == "reload_config" { + return ReloadConfigCommand() + } else if raw == "close_all_windows_but_current" { + return CloseAllWindowsButCurrentCommand() + } else if raw == "" { + error("\(backtrace): Can't parse empty string command") + } else { + error("\(backtrace): Can't parse '\(raw)' command") + } +} + +private func parseSingleArg(_ args: ArraySlice, _ command: String, _ backtrace: TomlBacktrace) -> String { + args.singleOrNil().flatMap { String($0) } ?? errorT( + "\(backtrace): \(command) must have only a single argument. But passed: '\(args.joined(separator: " "))'" + ) +} + +private func expectedActualTypeError(expected: TOMLType, actual: TOMLType, _ backtrace: TomlBacktrace) -> T { + error("\(backtrace): Expected type is '\(expected)'. But actual type is '\(actual)'") +} + +private func expectedActualTypeError(expected: [TOMLType], actual: TOMLType, _ backtrace: TomlBacktrace) -> T { + if let single = expected.singleOrNil() { + return expectedActualTypeError(expected: single, actual: actual, backtrace) + } else { + error("\(backtrace): Expected types are \(expected.map { "'\($0.description)'" }.joined(separator: " or ")). But actual type is '\(actual)'") + } +} diff --git a/src/config/Config.swift b/src/config/Config.swift index e9bc4242..c742d9d0 100644 --- a/src/config/Config.swift +++ b/src/config/Config.swift @@ -6,6 +6,8 @@ struct Config { let autoFlattenContainers: Bool let floatingWindowsOnTop: Bool let modes: [String: Mode] + var workspaceNames: [String] + var mainMode: Mode { modes[mainModeId] ?? errorT("Invalid config. main mode must be always presented") } } struct Mode { @@ -39,7 +41,7 @@ class HotkeyBinding { } func activate() { - hotKey = HotKey(key: key, modifiers: modifiers, keyUpHandler: { [self] in + hotKey = HotKey(key: key, modifiers: modifiers, keyUpHandler: { [command] in Task { await command.run() } }) } diff --git a/src/config/parseConfig.swift b/src/config/parseConfig.swift index 577f0259..73cbee29 100644 --- a/src/config/parseConfig.swift +++ b/src/config/parseConfig.swift @@ -61,7 +61,13 @@ func parseConfig(_ rawToml: String) -> Config { usePaddingForNestedContainersWithTheSameOrientation: value2 ?? defaultConfig.usePaddingForNestedContainersWithTheSameOrientation, autoFlattenContainers: value3 ?? defaultConfig.autoFlattenContainers, floatingWindowsOnTop: value4 ?? defaultConfig.floatingWindowsOnTop, - modes: modes ?? defaultConfig.modes + modes: modes ?? defaultConfig.modes, + workspaceNames: (modes ?? defaultConfig.modes).values.lazy + .flatMap { (mode: Mode) -> [HotkeyBinding] in mode.bindings } + .flatMap { (binding: HotkeyBinding) -> [any Command] in + (binding.command as? CompositeCommand)?.subCommands ?? [binding.command] + } + .compactMap { (command: Command) -> String? in (command as? WorkspaceCommand)?.workspaceName ?? nil } ) } @@ -119,56 +125,7 @@ private func parseBool(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) raw.bool ?? expectedActualTypeError(expected: .bool, actual: raw.type, backtrace) } -private func parseCommand(_ raw: TOMLValueConvertible, _ backtrace: TomlBacktrace) -> Command { - if let rawString = raw.string { - return parseSingleCommand(rawString, backtrace) - } else if let rawArray = raw.array { - let commands: [Command] = (0.. Command { - let words = raw.split(separator: " ") - let args = words[1...] - let firstWord = String(words.first ?? "") - if firstWord == "workspace" { - return WorkspaceCommand(workspaceName: parseSingleArg(args, firstWord, backtrace)) - } else if firstWord == "mode" { - return ModeCommand(idToActivate: parseSingleArg(args, firstWord, backtrace)) - } else if firstWord == "bash" { - return BashCommand(bashCommand: raw.removePrefix("bash")) - } else if firstWord == "focus" { - let direction = FocusCommand.Direction(rawValue: parseSingleArg(args, firstWord, backtrace)) - ?? errorT("\(backtrace): Can't parse 'focus' direction") - return FocusCommand(direction: direction) - } else if firstWord == "move_through" { - let direction = MoveThroughCommand.Direction(rawValue: parseSingleArg(args, firstWord, backtrace)) - ?? errorT("\(backtrace): Can't parse 'move_through' direction") - return MoveThroughCommand(direction: direction) - } else if raw == "reload_config" { - return ReloadConfigCommand() - } else if raw == "" { - error("\(backtrace): Can't parse empty string command") - } else { - error("\(backtrace): Can't parse '\(raw)' command") - } -} - -private func parseSingleArg(_ args: ArraySlice, _ command: String, _ backtrace: TomlBacktrace) -> String { - args.singleOrNil().flatMap { String($0) } ?? errorT( - "\(backtrace): \(command) must have only a single argument. But passed: '\(args.joined(separator: " "))'" - ) -} - -private indirect enum TomlBacktrace: CustomStringConvertible { +indirect enum TomlBacktrace: CustomStringConvertible { case root(String) case key(String) case index(Int) @@ -199,11 +156,3 @@ private func unknownKeyError(_ backtrace: TomlBacktrace) -> Never { private func expectedActualTypeError(expected: TOMLType, actual: TOMLType, _ backtrace: TomlBacktrace) -> T { error("\(backtrace): Expected type is '\(expected)'. But actual type is '\(actual)'") } - -private func expectedActualTypeError(expected: [TOMLType], actual: TOMLType, _ backtrace: TomlBacktrace) -> T { - if let single = expected.singleOrNil() { - return expectedActualTypeError(expected: single, actual: actual, backtrace) - } else { - error("\(backtrace): Expected types are \(expected.map { "'\($0.description)'" }.joined(separator: " or ")). But actual type is '\(actual)'") - } -} diff --git a/src/axWrappers/MacApp.swift b/src/model/MacApp.swift similarity index 100% rename from src/axWrappers/MacApp.swift rename to src/model/MacApp.swift diff --git a/src/axWrappers/MacWindow.swift b/src/model/MacWindow.swift similarity index 89% rename from src/axWrappers/MacWindow.swift rename to src/model/MacWindow.swift index 88f4b830..8d0809c5 100644 --- a/src/axWrappers/MacWindow.swift +++ b/src/model/MacWindow.swift @@ -31,11 +31,11 @@ class MacWindow: TreeNode, Hashable { if let existing = allWindowsMap[id] { return existing } else { - let activeWorkspace = Workspace.get(byName: ViewModel.shared.focusedWorkspaceTrayText) + let focusedWorkspace = Workspace.get(byName: TrayModel.shared.focusedWorkspaceTrayText) let workspace: Workspace // todo rewrite. Window is appeared on empty space - if activeWorkspace == currentEmptyWorkspace && - NSWorkspace.activeApp == app.nsApp && + if focusedWorkspace == currentEmptyWorkspace && + NSWorkspace.focusedApp == app.nsApp && app.axFocusedWindow?.windowId() == axWindow.windowId() { workspace = currentEmptyWorkspace } else { @@ -49,7 +49,7 @@ class MacWindow: TreeNode, Hashable { parent = workspace weight = FLOATING_ADAPTIVE_WEIGHT } else { - let tilingParent = workspace.lastActiveWindow?.parent as? TilingContainer ?? workspace.rootTilingContainer + let tilingParent = workspace.mruWindows.mru?.parent as? TilingContainer ?? workspace.rootTilingContainer parent = tilingParent weight = parent.children.sumOf { $0.getWeight(tilingParent.orientation) } .div(parent.children.count) ?? 1 @@ -92,9 +92,10 @@ class MacWindow: TreeNode, Hashable { } @discardableResult - func activate() -> Bool { + func focus() -> Bool { if app.nsApp.activate(options: .activateIgnoringOtherApps) && axWindow.raise() { - NSWorkspace.activeApp = app.nsApp + workspace.mruWindows.pushOrRaise(self) + NSWorkspace.focusedApp = app.nsApp return true } else { return false @@ -156,10 +157,18 @@ class MacWindow: TreeNode, Hashable { axWindow.set(Ax.topLeftCornerAttr, point) } - func getTopLeftCorner() -> CGPoint? { + private func getTopLeftCorner() -> CGPoint? { axWindow.get(Ax.topLeftCornerAttr) } + func getRect() -> Rect? { + guard let topLeftCorner = getTopLeftCorner() else { return nil } + guard let size = getSize() else { return nil } + return Rect(topLeftX: topLeftCorner.x, topLeftY: topLeftCorner.y, width: size.width, height: size.height) + } + + func getCenter() -> CGPoint? { getRect()?.center } + static func garbageCollectClosedWindows() { for window in allWindows { if window.axWindow.windowId() == nil { @@ -186,4 +195,4 @@ extension MacWindow { } } -let FLOATING_ADAPTIVE_WEIGHT = CGFloat(0) +let FLOATING_ADAPTIVE_WEIGHT = CGFloat(-1) diff --git a/src/model/TilingContainer.swift b/src/model/TilingContainer.swift index 19d15fd4..0ebb9ef0 100644 --- a/src/model/TilingContainer.swift +++ b/src/model/TilingContainer.swift @@ -51,9 +51,9 @@ extension TilingContainer { } enum Orientation { - /// Windows are aligned along the **horizontal** line + /// Windows are planced along the **horizontal** line case H - /// Windows are aligned along the **vertical** line + /// Windows are planced along the **vertical** line case V } diff --git a/src/model/TreeNode.swift b/src/model/TreeNode.swift index 4a61a965..409321ab 100644 --- a/src/model/TreeNode.swift +++ b/src/model/TreeNode.swift @@ -59,9 +59,9 @@ class TreeNode: Equatable { } if let window = self as? MacWindow { let newParentWorkspace = newParent.workspace - newParentWorkspace.lastActiveWindow = window - newParentWorkspace.assignedMonitor = window.getTopLeftCorner()?.monitorApproximation - // Update currentEmptyWorkspace if it's no longer empty + newParentWorkspace.mruWindows.pushOrRaise(window) + newParentWorkspace.assignedMonitor = window.getCenter()?.monitorApproximation + // Update currentEmptyWorkspace since it's no longer effectively empty if newParentWorkspace == currentEmptyWorkspace { currentEmptyWorkspace = getOrCreateNextEmptyWorkspace() } @@ -75,15 +75,18 @@ class TreeNode: Equatable { if _parent == nil { return nil } + let workspace = workspace + if let window = self as? MacWindow { + workspace.mruWindows.remove(window) + } + let index = parent._children.remove(element: self) ?? errorT("Can't find child in its parent") - let workspace: Workspace = parent.workspace + _parent = nil + if workspace.isEffectivelyEmpty { // It became empty currentEmptyWorkspace = workspace currentEmptyWorkspace.assignedMonitor = nil } - if let window = self as? MacWindow, parent.workspace.lastActiveWindow == window { - parent.workspace.lastActiveWindow = nil - } return PreviousBindingData(adaptiveWeight: adaptiveWeight, index: index) } diff --git a/src/model/TreeNodeEx.swift b/src/model/TreeNodeEx.swift index 78c0d405..f83e26c7 100644 --- a/src/model/TreeNodeEx.swift +++ b/src/model/TreeNodeEx.swift @@ -9,23 +9,42 @@ extension TreeNode { visit(node: child, result: &result) } } - - var allWindowsRecursive: [MacWindow] { + var allLeafWindowsRecursive: [MacWindow] { var result: [MacWindow] = [] visit(node: self, result: &result) return result } + var parents: [TreeNode] { self is Workspace ? [] : [parent] + parent.parents } + var parentsWithSelf: [TreeNode] { self is Workspace ? [self] : [self] + parent.parentsWithSelf } + var workspace: Workspace { self as? Workspace ?? parent.workspace } - var anyChildWindowRecursive: MacWindow? { + func allLeafWindowsRecursive(snappedTo: Direction) -> [MacWindow] { + if let workspace = self as? Workspace { + return workspace.rootTilingContainer.allLeafWindowsRecursive(snappedTo: snappedTo) + } else if let window = self as? MacWindow { + return [window] + } else if let container = self as? TilingContainer { + if snappedTo.orientation == container.orientation { + return (snappedTo.isPositive ? container.children.last : container.children.first)? + .allLeafWindowsRecursive(snappedTo: snappedTo) ?? [] + } else { + return children.flatMap { $0.allLeafWindowsRecursive(snappedTo: snappedTo) } + } + } else { + error("Not supported TreeNode type: \(Self.self)") + } + } + + var anyLeafWindowRecursive: MacWindow? { if let window = children.first(where: { $0 is MacWindow }) { return (window as! MacWindow) } for child in children { - if let window = child.anyChildWindowRecursive { + if let window = child.anyLeafWindowRecursive { return window } } @@ -34,7 +53,7 @@ extension TreeNode { // Doesn't contain at least one window var isEffectivelyEmpty: Bool { - anyChildWindowRecursive == nil + anyLeafWindowRecursive == nil } var hWeight: CGFloat { diff --git a/src/model/Workspace.swift b/src/model/Workspace.swift index 1ab14a0a..94aa84e9 100644 --- a/src/model/Workspace.swift +++ b/src/model/Workspace.swift @@ -45,7 +45,8 @@ class Workspace: TreeNode, Hashable, Identifiable { let name: String var id: String { name } // satisfy Identifiable var assignedMonitor: Monitor? = nil - weak var lastActiveWindow: MacWindow? + var mruWindows: MruStack = MruStack() + //weak var lastActiveWindow: MacWindow? private init(_ name: String) { self.name = name @@ -81,7 +82,7 @@ class Workspace: TreeNode, Hashable, Identifiable { } static func garbageCollectUnusedWorkspaces() { - let preservedNames = settings.map { $0.name }.toSet() + let preservedNames = config.workspaceNames.toSet() for name in preservedNames { _ = get(byName: name) // Make sure that all preserved workspaces are "cached" } @@ -89,7 +90,7 @@ class Workspace: TreeNode, Hashable, Identifiable { preservedNames.contains(workspace.name) || !workspace.isEffectivelyEmpty || workspace == currentEmptyWorkspace || - workspace.name == ViewModel.shared.focusedWorkspaceTrayText + workspace.name == TrayModel.shared.focusedWorkspaceTrayText } } diff --git a/src/refresh.swift b/src/refresh.swift index 14509529..377101aa 100644 --- a/src/refresh.swift +++ b/src/refresh.swift @@ -6,7 +6,7 @@ func refresh(startSession: Bool = true, endSession: Bool = true) { precondition(Thread.current.isMainThread) debug("refresh (startSession=\(startSession), endSession=\(endSession)) \(Date.now.formatted(date: .abbreviated, time: .standard))") if startSession { - NSWorkspace.activeApp = nil + NSWorkspace.focusedApp = nil MacWindow.garbageCollectClosedWindows() // Garbage collect terminated apps and windows before working with all windows MacApp.garbageCollectTerminatedApps() @@ -27,63 +27,41 @@ func refresh(startSession: Bool = true, endSession: Bool = true) { } } -func updateLastActiveWindow() { - guard let window = NSWorkspace.activeApp?.macApp?.focusedWindow else { return } - window.workspace.lastActiveWindow = window -} - -func switchToWorkspace(_ workspace: Workspace) { - debug("Switch to workspace: \(workspace.name)") - refresh(endSession: false) - if let window = workspace.lastActiveWindow ?? workspace.anyChildWindowRecursive { // switch to not empty workspace - window.activate() - // The switching itself will be done by refreshWorkspaces and materializeWorkspaces later in refresh - } else { // switch to empty workspace - precondition(workspace.isEffectivelyEmpty) - // It's the only place in the app where I allow myself to use NSScreen.main. - // This function isn't invoked from callbacks that's why .main should be fine - if let focusedMonitor = NSScreen.focusedMonitorOrNilIfDesktop ?? NSScreen.main?.monitor { - focusedMonitor.setActiveWorkspace(workspace) - } - defocusAllWindows() - } - refresh(startSession: false) - debug("End switch to workspace: \(workspace.name)") +func refreshObs(_ obs: AXObserver, ax: AXUIElement, notif: CFString, data: UnsafeMutableRawPointer?) { + refresh() } -private func defocusAllWindows() { - // Since AeroSpace doesn't show any windows, focusing AeroSpace defocuses all windows - let current = NSRunningApplication.current - current.activate(options: .activateIgnoringOtherApps) - NSWorkspace.activeApp = current +func updateLastActiveWindow() { + guard let window = NSWorkspace.focusedApp?.macApp?.focusedWindow else { return } + window.workspace.mruWindows.pushOrRaise(window) } private func refreshWorkspaces() { - if let focusedWindow = NSWorkspace.activeApp?.macApp?.focusedWindow { + if let focusedWindow = NSWorkspace.focusedApp?.macApp?.focusedWindow { debug("refreshWorkspaces: not empty") let focusedWorkspace: Workspace if focusedWindow.isFloating && !focusedWindow.isHiddenViaEmulation { - focusedWorkspace = focusedWindow.getTopLeftCorner()?.monitorApproximation.getActiveWorkspace() + focusedWorkspace = focusedWindow.getCenter()?.monitorApproximation.getActiveWorkspace() ?? focusedWindow.workspace focusedWindow.bindAsFloatingWindowTo(workspace: focusedWorkspace) } else { focusedWorkspace = focusedWindow.workspace } focusedWorkspace.assignedMonitorOfNotEmptyWorkspace.setActiveWorkspace(focusedWorkspace) - ViewModel.shared.focusedWorkspaceTrayText = focusedWorkspace.name + TrayModel.shared.focusedWorkspaceTrayText = focusedWorkspace.name } else { debug("refreshWorkspaces: empty") - ViewModel.shared.focusedWorkspaceTrayText = currentEmptyWorkspace.name + TrayModel.shared.focusedWorkspaceTrayText = currentEmptyWorkspace.name } } private func layoutWorkspaces() { for workspace in Workspace.all { - debug("materializeWorkspaces: \(workspace.name) visible=\(workspace.isVisible)") + debug("layoutWorkspaces: \(workspace.name) visible=\(workspace.isVisible)") if workspace.isVisible { - workspace.allWindowsRecursive.forEach { $0.unhideViaEmulation() } + workspace.allLeafWindowsRecursive.forEach { $0.unhideViaEmulation() } } else { - workspace.allWindowsRecursive.forEach { $0.hideViaEmulation() } + workspace.allLeafWindowsRecursive.forEach { $0.hideViaEmulation() } } } } diff --git a/src/util/CollectionEx.swift b/src/util/CollectionEx.swift index 956e7e51..8f93523f 100644 --- a/src/util/CollectionEx.swift +++ b/src/util/CollectionEx.swift @@ -2,4 +2,8 @@ extension Collection { func singleOrNil() -> Element? { count == 1 ? first : nil } + + func getOrNil(atIndex index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } } \ No newline at end of file diff --git a/src/util/Direction.swift b/src/util/Direction.swift new file mode 100644 index 00000000..f8cd49e1 --- /dev/null +++ b/src/util/Direction.swift @@ -0,0 +1,20 @@ +enum Direction: String { + case left, down, up, right +} + +extension Direction { + var orientation: Orientation { self == .up || self == .down ? .V : .H } + var isPositive: Bool { self == .down || self == .right } + var opposite: Direction { + switch self { + case .left: + return .right + case .down: + return .up + case .up: + return .down + case .right: + return .left + } + } +} diff --git a/src/util/MruStack.swift b/src/util/MruStack.swift new file mode 100644 index 00000000..1bfff2fe --- /dev/null +++ b/src/util/MruStack.swift @@ -0,0 +1,73 @@ +/// Stack with most recently element on top +struct MruStack: Sequence { + typealias Iterator = MruStackIterator + typealias Element = T + + private var mruNode: Node? = nil + + func makeIterator() -> MruStackIterator { + MruStackIterator(mruNode) + } + + var mru: T? { mruNode?.value } + + mutating func pushOrRaise(_ value: T) { + remove(value) + mruNode = Node(value, mruNode) + } + + @discardableResult + func remove(_ value: T) -> Bool { + var prev: Node? = nil + var current = mruNode + while current != nil { + if current?.value == value { + prev?.next = current?.next + current?.next = nil + return true + } + prev = current + current = current?.next + } + return false + } +} + +extension MruStack where T: Hashable { + var mruIndexMap: [T: Int] { + var result: [T: Int] = [:] + for indexed in self.lazy.withIndex { + result[indexed.value] = indexed.index + } + return result + } +} + +struct MruStackIterator: IteratorProtocol { + typealias Element = T + private var current: Node? + + fileprivate init(_ current: Node?) { + self.current = current + } + + mutating func next() -> T? { + let result = current?.value + current = current?.next + return result + } +} + +private class Node { + var next: Node? = nil + let value: T + + init(_ value: T, _ next: Node?) { + self.value = value + self.next = next + } + + init(_ value: T) { + self.value = value + } +} diff --git a/src/util/NSScreenEx.swift b/src/util/NSScreenEx.swift index 2ed5daf8..b024969a 100644 --- a/src/util/NSScreenEx.swift +++ b/src/util/NSScreenEx.swift @@ -8,7 +8,7 @@ extension NSScreen { /// /// Returns `nil` if the desktop is selected (which is when the app is active but doesn't show any window) static var focusedMonitorOrNilIfDesktop: Monitor? { - NSWorkspace.activeApp?.macApp?.focusedWindow?.getTopLeftCorner()?.monitorApproximation + NSWorkspace.focusedApp?.macApp?.focusedWindow?.getCenter()?.monitorApproximation ?? NSScreen.screens.singleOrNil()?.monitor //NSWorkspace.activeApp?.macApp?.axFocusedWindow? diff --git a/src/util/NSWorkspaceEx.swift b/src/util/NSWorkspaceEx.swift index 2ca5b26e..90d0e49d 100644 --- a/src/util/NSWorkspaceEx.swift +++ b/src/util/NSWorkspaceEx.swift @@ -5,7 +5,7 @@ extension NSWorkspace { private static var _activeApp: NSRunningApplication? = nil - static var activeApp: NSRunningApplication? { + static var focusedApp: NSRunningApplication? { /// Force assign currently active app in scope of this session set { _activeApp = newValue } get { _activeApp ?? NSWorkspace.shared.frontmostApplication } diff --git a/src/util/Rect.swift b/src/util/Rect.swift index ec168d83..2c94e3e0 100644 --- a/src/util/Rect.swift +++ b/src/util/Rect.swift @@ -49,6 +49,10 @@ extension Rect { ) } + var center: CGPoint { + CGPoint(x: topLeftX + width / 2, y: topLeftY + height / 2) + } + var topLeftCorner: CGPoint { CGPoint(x: topLeftX, y: topLeftY) } var topRightCorner: CGPoint { CGPoint(x: maxX, y: minY) } var bottomRightCorner: CGPoint { CGPoint(x: maxX, y: maxY) } diff --git a/src/util/SequenceEx.swift b/src/util/SequenceEx.swift index 5fa10edc..fe911c03 100644 --- a/src/util/SequenceEx.swift +++ b/src/util/SequenceEx.swift @@ -42,6 +42,19 @@ extension Sequence { } return result } + + var withIndex: [Indexed] { + var index = -1 + return map { + index += 1 + return Indexed(index: index, value: $0) + } + } +} + +struct Indexed { + let index: Int + let value: T } extension Sequence where Self.Element: Comparable { diff --git a/src/accessibility.swift b/src/util/accessibility.swift similarity index 98% rename from src/accessibility.swift rename to src/util/accessibility.swift index b8c3e4fb..758a56f5 100644 --- a/src/accessibility.swift +++ b/src/util/accessibility.swift @@ -143,5 +143,11 @@ extension AXObserver { } } +struct AxObserverWrapper { + let obs: AXObserver + let ax: AXUIElement + let notif: CFString +} + /// Pure heuristic. Usually it takes around 1000 attempts to subscribe private let SUBSCRIBE_OBSERVER_ATTEMPTS_THRESHOLD = 10_000 diff --git a/src/util/utils.swift b/src/util/utils.swift index 003a1c2a..0d62bb14 100644 --- a/src/util/utils.swift +++ b/src/util/utils.swift @@ -3,16 +3,6 @@ import Cocoa import CoreFoundation import AppKit -func test() { - for screen in NSScreen.screens { - debug("---") - debug(screen.localizedName) - debug(screen.debugDescription) - debug(screen.visibleRect.topLeftCorner) - debug(screen.visibleRect) - } -} - func stringType(of some: Any) -> String { let string = (some is Any.Type) ? String(describing: some) : String(describing: type(of: some)) return string