From 5eade75983c8f6d21c35b9384c8d09084592d055 Mon Sep 17 00:00:00 2001 From: Louis Pontoise Date: Wed, 1 Apr 2020 16:09:21 +0900 Subject: [PATCH] feat: more flexible shortcuts (closes #72) closes #50, closes #125, closes #133 --- Podfile | 2 +- Podfile.lock | 15 +- alt-tab-macos.xcodeproj/project.pbxproj | 22 ++- src/api-wrappers/AXUIElement.swift | 4 + src/api-wrappers/PrivateApis.swift | 1 + src/logic/Application.swift | 13 +- src/logic/Keyboard.swift | 59 ++----- src/logic/Preferences.swift | 160 ++++++++++++------ src/logic/Screen.swift | 16 +- src/logic/Spaces.swift | 15 +- src/logic/Window.swift | 1 + src/logic/Windows.swift | 48 ++++-- src/ui/App.swift | 31 ++-- src/ui/generic-components/GridView.swift | 3 + src/ui/generic-components/StackView.swift | 19 +++ .../UnclearableRecorderControl.swift | 29 ++++ .../generic-components/text/TextField.swift | 36 ---- src/ui/main-window/ThumbnailView.swift | 4 +- src/ui/main-window/ThumbnailsView.swift | 2 + .../preferences-window/LabelAndControl.swift | 68 +++++--- .../PreferencesWindow.swift | 33 ++-- .../TabViewController.swift | 12 +- src/ui/preferences-window/tabs/AboutTab.swift | 22 +-- .../tabs/AppearanceTab.swift | 16 +- .../preferences-window/tabs/GeneralTab.swift | 112 +++++++++--- .../preferences-window/tabs/UpdatesTab.swift | 10 +- 26 files changed, 479 insertions(+), 274 deletions(-) create mode 100644 src/ui/generic-components/StackView.swift create mode 100644 src/ui/generic-components/UnclearableRecorderControl.swift delete mode 100644 src/ui/generic-components/text/TextField.swift diff --git a/Podfile b/Podfile index de4594bd..d6f9ea31 100644 --- a/Podfile +++ b/Podfile @@ -4,5 +4,5 @@ target 'alt-tab-macos' do use_frameworks! pod 'LetsMove', '1.24' pod 'Sparkle', '1.23.0' + pod 'ShortcutRecorder', :git => 'https://github.com/Kentzo/ShortcutRecorder.git', :branch => 'issue-114' end - diff --git a/Podfile.lock b/Podfile.lock index 1e903bb5..3bc66c18 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,9 +1,11 @@ PODS: - LetsMove (1.24) + - ShortcutRecorder (3.1) - Sparkle (1.23.0) DEPENDENCIES: - LetsMove (= 1.24) + - ShortcutRecorder (from `https://github.com/Kentzo/ShortcutRecorder.git`, branch `issue-114`) - Sparkle (= 1.23.0) SPEC REPOS: @@ -11,10 +13,21 @@ SPEC REPOS: - LetsMove - Sparkle +EXTERNAL SOURCES: + ShortcutRecorder: + :branch: issue-114 + :git: https://github.com/Kentzo/ShortcutRecorder.git + +CHECKOUT OPTIONS: + ShortcutRecorder: + :commit: 4aebe01e1ec9eebb520ef50e42bc321be473c580 + :git: https://github.com/Kentzo/ShortcutRecorder.git + SPEC CHECKSUMS: LetsMove: fefe56bc7bc7fb7d37049e28a14f297961229fc5 + ShortcutRecorder: fdf620aca34101b0cba3b10fca815e0459254189 Sparkle: 55b1a87ba69d56913375a281546b7c82dec95bb0 -PODFILE CHECKSUM: 465451026269525f0f1d2dc7053cf0b789a35421 +PODFILE CHECKSUM: 7a88a7e0e87002ed5ea9c531fdea213ac6657496 COCOAPODS: 1.8.4 diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index b2efcbce..4eabfef1 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ D04BA11E56383D082D7BE5A5 /* ThumbnailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAA998119CAA8B70A2B67 /* ThumbnailsView.swift */; }; D04BA14D93726795A6937832 /* LabelAndControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2526DC6726E0F7ACF7C /* LabelAndControl.swift */; }; D04BA15A1B0C4871EA7CB899 /* GeneralTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACE22DC907F03D193075 /* GeneralTab.swift */; }; + D04BA1621718FF26F8D5E75D /* UnclearableRecorderControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE333F7170E87C5AC0EF /* UnclearableRecorderControl.swift */; }; + D04BA1637E125D38546C26E2 /* StackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8AC77465E1B2BC947CF /* StackView.swift */; }; D04BA1B133D53572D7B312C2 /* ThumbnailFontIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA1DF8CAB2FAB7FE9244B /* ThumbnailFontIconView.swift */; }; D04BA1C85041FB043FD43E65 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA05006D56C7AA011C0CE /* Localizable.strings */; }; D04BA1CEC6B9C8945FEC8740 /* ThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA258B56193958D60978A /* ThumbnailView.swift */; }; @@ -33,7 +35,6 @@ D04BA40CC1415DA69CCE5D89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA17FC84640580894400E /* InfoPlist.strings */; }; D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACABD048E62EBE4576CC /* DebugProfile.swift */; }; D04BA570E7806F28741B1472 /* SF-Pro-Text-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = D04BA0CDCFF1F0B1A77E3E86 /* SF-Pro-Text-Regular.otf */; }; - D04BA5F99B45DC13B9E9DD91 /* Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8276B3D3905E80B1739 /* Keyboard.swift */; }; D04BA6187A91A847844B6ABB /* Window.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA015A45DE7AFDC9794FE /* Window.swift */; }; D04BA691CB6082A3C39CBC89 /* TabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE757BB2B605234FBF58 /* TabViewController.swift */; }; D04BA69D47B5E60A6AD9CBD9 /* ThumbnailTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1297730B191E96E7FE /* ThumbnailTitleView.swift */; }; @@ -42,6 +43,7 @@ D04BA748B2BECEDF57750E5F /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA447E4AB0750BA7857FE /* InfoPlist.strings */; }; D04BA76A74267B1346D23687 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA6D57A1456C07318B8EA /* GridView.swift */; }; D04BA775CF3F8D9394A1E256 /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA68C2561D9EE4FD851B8 /* Screen.swift */; }; + D04BA78B5D288BF072050C43 /* Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAA857B1AB33FBCC59A86 /* Keyboard.swift */; }; D04BA7BE7F3DD24D58ACE942 /* AppearanceTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA64F1F344007EA13BA05 /* AppearanceTab.swift */; }; D04BA7F86F1926FBE31F44BF /* BaseLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA53992F116E5E704CAB3 /* BaseLabel.swift */; }; D04BA8092885B40CE3527370 /* UpdatesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD60C97E609A759E721E /* UpdatesTab.swift */; }; @@ -70,7 +72,6 @@ D04BAE6A13DBAE85B8F7CB6A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BABF92E92F093C18BB1A1 /* Localizable.strings */; }; D04BAEE31B6FFCDC779E6C17 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D04BAC2FF99F629CD4ED20FC /* MainMenu.xib */; }; D04BAF12DF5D15B9D7D316A4 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA61693F710CD7BD054D7 /* InfoPlist.strings */; }; - D04BAF25E67A5B31CF7676DB /* TextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA44F7B5E58A08416706B /* TextField.swift */; }; D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BACD976030676FD0761D5 /* Windows.swift */; }; D04BAFBC862BA5FE0294EA7A /* AXUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA6F823BC0EDA9AA4B80A /* AXUIElement.swift */; }; /* End PBXBuildFile section */ @@ -113,7 +114,6 @@ D04BA399F1DF2C61FC2C9599 /* menubar-icon@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-icon@2x.png"; sourceTree = ""; }; D04BA4135D97F9E784709DA8 /* ko */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ko; path = InfoPlist.strings; sourceTree = ""; }; D04BA4336B6004A0A99849AD /* package.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = package.json; sourceTree = ""; }; - D04BA44F7B5E58A08416706B /* TextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextField.swift; sourceTree = ""; }; D04BA459034C1885CA43A807 /* LICENCE.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = LICENCE.md; sourceTree = ""; }; D04BA47FF1B7838CF4814538 /* PreferencesWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesWindow.swift; sourceTree = ""; }; D04BA49E45BFFF3D9FC60E43 /* HyperlinkLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HyperlinkLabel.swift; sourceTree = ""; }; @@ -145,13 +145,14 @@ D04BA7C836A8CE8C0B8D128B /* TextArea.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextArea.swift; sourceTree = ""; }; D04BA7CF9C2D1BEC7C05AB24 /* Spaces.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spaces.swift; sourceTree = ""; }; D04BA7ECCE728582D9ECA613 /* determine_version_and_changelog.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = determine_version_and_changelog.sh; sourceTree = ""; }; - D04BA8276B3D3905E80B1739 /* Keyboard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keyboard.swift; sourceTree = ""; }; D04BA82F792DF53958D92572 /* AltTab.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AltTab.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D04BA8AC77465E1B2BC947CF /* StackView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StackView.swift; sourceTree = ""; }; D04BA8DB8AA7E5570DAC568A /* Sysctl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Sysctl.swift; sourceTree = ""; }; D04BA93F02C9B26B05032B7A /* ja */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ja; path = Localizable.strings; sourceTree = ""; }; D04BA9B93823398A542FF7A0 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D04BA9EF65B2E7AF9E3ADCA3 /* 2 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2 windows - 1 line.jpg"; sourceTree = ""; }; D04BAA34E0CB00DED7C04B4F /* 2-rows.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2-rows.jpg"; sourceTree = ""; }; + D04BAA857B1AB33FBCC59A86 /* Keyboard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keyboard.swift; sourceTree = ""; }; D04BAA998119CAA8B70A2B67 /* ThumbnailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsView.swift; sourceTree = ""; }; D04BAA9E0539EE620D08F63F /* fr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = fr; path = InfoPlist.strings; sourceTree = ""; }; D04BAAB92261FC04854FDDE9 /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; @@ -185,6 +186,7 @@ D04BADB20AB31BF83593E0BE /* greetings.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = greetings.yml; sourceTree = ""; }; D04BAE1243C9B4BE3ED1B524 /* 7 windows - 2 lines - extra wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - extra wide window.jpg"; sourceTree = ""; }; D04BAE2DC036FD84446E1AE6 /* menubar-icon.svg */ = {isa = PBXFileReference; lastKnownFileType = file.svg; path = "menubar-icon.svg"; sourceTree = ""; }; + D04BAE333F7170E87C5AC0EF /* UnclearableRecorderControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnclearableRecorderControl.swift; sourceTree = ""; }; D04BAE5D665680CB4B13CA26 /* app-icon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = "app-icon.icns"; sourceTree = ""; }; D04BAE757BB2B605234FBF58 /* TabViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabViewController.swift; sourceTree = ""; }; D04BAE93A5854C501639C640 /* update_homebrew_cask.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = update_homebrew_cask.sh; sourceTree = ""; }; @@ -239,7 +241,6 @@ D04BA0D80B24E72B9B981A1D /* logic */ = { isa = PBXGroup; children = ( - D04BA8276B3D3905E80B1739 /* Keyboard.swift */, D04BACD976030676FD0761D5 /* Windows.swift */, D04BA9B93823398A542FF7A0 /* Preferences.swift */, D04BA68C2561D9EE4FD851B8 /* Screen.swift */, @@ -250,6 +251,7 @@ D04BA282BB16C1554595A968 /* Applications.swift */, D04BAB74451B79FE18B8BEDF /* DispatchQueues.swift */, D04BACABD048E62EBE4576CC /* DebugProfile.swift */, + D04BAA857B1AB33FBCC59A86 /* Keyboard.swift */, ); path = logic; sourceTree = ""; @@ -335,6 +337,8 @@ children = ( D04BA6D57A1456C07318B8EA /* GridView.swift */, D04BAA41B9A74189B065D856 /* text */, + D04BAE333F7170E87C5AC0EF /* UnclearableRecorderControl.swift */, + D04BA8AC77465E1B2BC947CF /* StackView.swift */, ); path = "generic-components"; sourceTree = ""; @@ -498,7 +502,6 @@ children = ( D04BACEE8D430B8CAAD8C4CD /* BoldLabel.swift */, D04BA49E45BFFF3D9FC60E43 /* HyperlinkLabel.swift */, - D04BA44F7B5E58A08416706B /* TextField.swift */, D04BA7C836A8CE8C0B8D128B /* TextArea.swift */, D04BA53992F116E5E704CAB3 /* BaseLabel.swift */, ); @@ -738,12 +741,14 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos-frameworks.sh", "${BUILT_PRODUCTS_DIR}/LetsMove/LetsMove.framework", + "${BUILT_PRODUCTS_DIR}/ShortcutRecorder/ShortcutRecorder.framework", "${PODS_ROOT}/Sparkle/Sparkle.framework", "${PODS_ROOT}/Sparkle/Sparkle.framework.dSYM", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LetsMove.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ShortcutRecorder.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sparkle.framework", "${DWARF_DSYM_FOLDER_PATH}/Sparkle.framework.dSYM", ); @@ -766,7 +771,6 @@ D04BABED81800E18732912CC /* CGWindowID.swift in Sources */, D04BA26A691D56031FCCF00C /* Sysctl.swift in Sources */, D04BA8480A8FF466CA89DA5B /* main.swift in Sources */, - D04BA5F99B45DC13B9E9DD91 /* Keyboard.swift in Sources */, D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */, D04BAC011A71E0418154F8CD /* Preferences.swift in Sources */, D04BA775CF3F8D9394A1E256 /* Screen.swift in Sources */, @@ -795,10 +799,12 @@ D04BA76A74267B1346D23687 /* GridView.swift in Sources */, D04BAB4EB890853B5B9B2C61 /* BoldLabel.swift in Sources */, D04BAABE804F3769CE22BEB6 /* HyperlinkLabel.swift in Sources */, - D04BAF25E67A5B31CF7676DB /* TextField.swift in Sources */, D04BAD2A7F2E8BF64EE982E9 /* TextArea.swift in Sources */, D04BA7F86F1926FBE31F44BF /* BaseLabel.swift in Sources */, D04BA11E56383D082D7BE5A5 /* ThumbnailsView.swift in Sources */, + D04BA1621718FF26F8D5E75D /* UnclearableRecorderControl.swift in Sources */, + D04BA78B5D288BF072050C43 /* Keyboard.swift in Sources */, + D04BA1637E125D38546C26E2 /* StackView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index f7eabf18..6e73acd9 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -27,6 +27,10 @@ extension AXUIElement { return cgWindowId().level() == CGWindowLevelForKey(.normalWindow) } + func position() -> CGPoint? { + return value(kAXPositionAttribute, CGPoint.zero, .cgPoint) + } + func title() -> String? { return attribute(kAXTitleAttribute, String.self) } diff --git a/src/api-wrappers/PrivateApis.swift b/src/api-wrappers/PrivateApis.swift index 7a615c2c..23937cc1 100644 --- a/src/api-wrappers/PrivateApis.swift +++ b/src/api-wrappers/PrivateApis.swift @@ -235,6 +235,7 @@ func SLSRequestScreenCaptureAccess() -> UInt8 //} // //// returns the status of the "Displays have separate Spaces" system Preference +//// there is a public API for that: NSScreen.screensHaveSeparateSpaces //// * macOS 10.10+ //@_silgen_name("CGSGetSpaceManagementMode") //func CGSGetSpaceManagementMode(_ cid: CGSConnectionID) -> SpaceManagementMode diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 8e2de027..73e06bc4 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -48,8 +48,13 @@ class Application: NSObject { } func observeNewWindows() { - if let windows = axUiElement!.windows(), windows.count > 0 { - addWindows(windows.filter { $0.isActualWindow(runningApplication) && Windows.list.firstIndexThatMatches($0) == nil }) + if let windows = axUiElement!.windows() { + let actualWindows = windows.filter { + $0.isActualWindow(runningApplication) && Windows.list.firstIndexThatMatches($0) == nil + } + if actualWindows.count > 0 { + addWindows(actualWindows) + } } } @@ -62,7 +67,7 @@ class Application: NSObject { private func addWindows(_ axWindows: [AXUIElement]) { let windows = axWindows.map { Window($0, self) } Windows.list.insertAndScaleRecycledPool(windows, at: 0) - Windows.moveFocusedWindowIndexAfterWindowCreatedInBackground(windows.count) + Windows.cycleFocusedWindowIndex(windows.count) App.app.refreshOpenUi(windows) } @@ -121,7 +126,7 @@ private func eventWindowCreated(_ app: App, _ element: AXUIElement, _ applicatio guard Windows.list.firstIndexThatMatches(element) == nil else { return } let window = Window(element, application) Windows.list.insertAndScaleRecycledPool([window], at: 0) - Windows.moveFocusedWindowIndexAfterWindowCreatedInBackground(1) + Windows.cycleFocusedWindowIndex(1) app.refreshOpenUi([window]) } diff --git a/src/logic/Keyboard.swift b/src/logic/Keyboard.swift index f55d12d5..64507a5d 100644 --- a/src/logic/Keyboard.swift +++ b/src/logic/Keyboard.swift @@ -2,14 +2,14 @@ import Cocoa import Carbon.HIToolbox.Events class Keyboard { - static func listenToGlobalEvents(_ delegate: App) { - listenToGlobalKeyboardEvents(delegate) + static func listenToGlobalEvents() { + listenToGlobalKeyboardEvents() } } var eventTap: CFMachPort? -func listenToGlobalKeyboardEvents(_ app: App) { +func listenToGlobalKeyboardEvents() { DispatchQueues.keyboardEvents.async { let eventMask = [CGEventType.keyDown, CGEventType.keyUp, CGEventType.flagsChanged].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue) }) // CGEvent.tapCreate returns null if ensureAccessibilityCheckboxIsChecked() didn't pass @@ -19,7 +19,7 @@ func listenToGlobalKeyboardEvents(_ app: App) { options: .defaultTap, eventsOfInterest: eventMask, callback: keyboardHandler, - userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(app).toOpaque())) + userInfo: nil) let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) CGEvent.tapEnable(tap: eventTap!, enable: true) CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) @@ -27,49 +27,22 @@ func listenToGlobalKeyboardEvents(_ app: App) { } } -func dispatchWork(_ fn: @escaping () -> Void) -> Unmanaged? { - (App.shared as! App).uiWorkShouldBeDone = true - DispatchQueue.main.async { - fn() - } - return nil // previously focused app should not receive keys -} - -func dispatchWorkCurrentThread(_ fn: @escaping () -> Void) -> Unmanaged? { - (App.shared as! App).uiWorkShouldBeDone = false - fn() - return nil // previously focused app should not receive keys -} - -func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, appPointer: UnsafeMutableRawPointer?) -> Unmanaged? { - let app = Unmanaged.fromOpaque(appPointer!).takeUnretainedValue() +func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, cgEvent: CGEvent, userInfo: UnsafeMutableRawPointer?) -> Unmanaged? { if type == .keyDown || type == .keyUp || type == .flagsChanged { - if let event = NSEvent(cgEvent: event_) { - let isTab = event.keyCode == Preferences.tabKeyCode - let isMetaChanged = Preferences.metaKeyCodes.contains(event.keyCode) - let isMetaDown = event.modifierFlags.contains(Preferences.metaModifierFlag) - let isRightArrow = event.keyCode == kVK_RightArrow - let isLeftArrow = event.keyCode == kVK_LeftArrow - let isEscape = event.keyCode == kVK_Escape - if type == .keyDown && isEscape && app.appIsBeingUsed { - return dispatchWorkCurrentThread { app.hideUi() } - } else if isMetaDown && type == .keyDown { - if isTab && event.modifierFlags.contains(.shift) { - return dispatchWork { app.showUiOrCycleSelection(-1) } - } else if isTab { - return dispatchWork { app.showUiOrCycleSelection(1) } - } else if isRightArrow && app.appIsBeingUsed { - return dispatchWork { app.cycleSelection(1) } - } else if isLeftArrow && app.appIsBeingUsed { - return dispatchWork { app.cycleSelection(-1) } - } - } else if isMetaChanged && !isMetaDown { - return dispatchWorkCurrentThread { app.focusTarget() } + if let event_ = NSEvent(cgEvent: cgEvent), + // workaround: NSEvent.characters is not safe outside of the main thread; this is not documented by Apple + // see https://github.com/Kentzo/ShortcutRecorder/issues/114#issuecomment-606465340 + let event = NSEvent.keyEvent(with: event_.type, location: event_.locationInWindow, modifierFlags: event_.modifierFlags, + timestamp: event_.timestamp, windowNumber: event_.windowNumber, context: event_.context, characters: "", + charactersIgnoringModifiers: "", isARepeat: type == .flagsChanged ? false : event_.isARepeat, keyCode: event_.keyCode) { + let appWasBeingUsed = App.app.appIsBeingUsed + App.shortcutMonitor.handle(event, withTarget: nil) + if appWasBeingUsed || App.app.appIsBeingUsed { + return nil // focused app won't receive the event } } } else if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { CGEvent.tapEnable(tap: eventTap!, enable: true) } - // focused app will receive the event - return Unmanaged.passRetained(event_) + return Unmanaged.passRetained(cgEvent) // focused app will receive the event } diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index c6e2513b..57c2597f 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -12,12 +12,21 @@ class Preferences { "rowsCount": Float(3), "iconSize": Float(32), "fontHeight": Float(15), - "tabKeyCode": kVK_Tab, + "holdShortcut": "⌥", + "nextWindowShortcut": "⇥", + "previousWindowShortcut": "⇧⇥", + "cancelShortcut": "⎋", + "arrowKeysEnabled": true, + "mouseHoverEnabled": true, + "showMinimizedWindows": true, + "showHiddenWindows": true, "windowDisplayDelay": 0, - "metaKey": MacroPreferences.metaKeyList.keys.first!, - "theme": MacroPreferences.themeList.keys.first!, - "showOnScreen": MacroPreferences.showOnScreenList.keys.first!, - "alignThumbnails": MacroPreferences.alignThumbnailsList.keys.first!, + "theme": ThemePreference.macOs.rawValue, + "showOnScreen": ShowOnScreenPreference.active.rawValue, + "alignThumbnails": AlignThumbnailsPreference.left.rawValue, + "appsToShow": AppsToShowPreference.all.rawValue, + "spacesToShow": SpacesToShowPreference.all.rawValue, + "screensToShow": ScreensToShowPreference.all.rawValue, "hideSpaceNumberLabels": false, "startAtLogin": true, ] @@ -38,25 +47,32 @@ class Preferences { static var rowsCount: CGFloat { CGFloat(defaults.float(forKey: "rowsCount")) } static var iconSize: CGFloat { CGFloat(defaults.float(forKey: "iconSize")) } static var fontHeight: CGFloat { CGFloat(defaults.float(forKey: "fontHeight")) } - static var tabKeyCode: UInt16 { UInt16(defaults.integer(forKey: "tabKeyCode")) } + static var holdShortcut: String { defaults.string(forKey: "holdShortcut")! } + static var nextWindowShortcut: String { defaults.string(forKey: "nextWindowShortcut")! } + static var previousWindowShortcut: String { defaults.string(forKey: "previousWindowShortcut")! } + static var cancelShortcut: String { defaults.string(forKey: "cancelShortcut")! } + static var arrowKeysEnabled: Bool { defaults.bool(forKey: "arrowKeysEnabled") } + static var mouseHoverEnabled: Bool { defaults.bool(forKey: "mouseHoverEnabled") } + static var showMinimizedWindows: Bool { defaults.bool(forKey: "showMinimizedWindows") } + static var showHiddenWindows: Bool { defaults.bool(forKey: "showHiddenWindows") } static var windowDisplayDelay: DispatchTimeInterval { DispatchTimeInterval.milliseconds(defaults.integer(forKey: "windowDisplayDelay")) } static var hideSpaceNumberLabels: Bool { defaults.bool(forKey: "hideSpaceNumberLabels") } static var startAtLogin: Bool { defaults.bool(forKey: "startAtLogin") } // macro values - static var theme: Theme { MacroPreferences.themeList[defaults.string(forKey: "theme")!]! } - static var metaKey: MetaKey { MacroPreferences.metaKeyList[defaults.string(forKey: "metaKey")!]! } - static var showOnScreen: ShowOnScreenPreference { MacroPreferences.showOnScreenList[defaults.string(forKey: "showOnScreen")!]! } - static var alignThumbnails: AlignThumbnailsPreference { MacroPreferences.alignThumbnailsList[defaults.string(forKey: "alignThumbnails")!]! } + static var theme: ThemePreference { ThemePreference(rawValue: defaults.string(forKey: "theme")!)! } + static var showOnScreen: ShowOnScreenPreference { ShowOnScreenPreference(rawValue: defaults.string(forKey: "showOnScreen")!)! } + static var alignThumbnails: AlignThumbnailsPreference { AlignThumbnailsPreference(rawValue: defaults.string(forKey: "alignThumbnails")!)! } + static var appsToShow: AppsToShowPreference { AppsToShowPreference(rawValue: defaults.string(forKey: "appsToShow")!)! } + static var spacesToShow: SpacesToShowPreference { SpacesToShowPreference(rawValue: defaults.string(forKey: "spacesToShow")!)! } + static var screensToShow: ScreensToShowPreference { .showingAltTab }//ScreensToShowPreference(rawValue: defaults.string(forKey: "screensToShow")!)! } // derived values - static var cellBorderWidth: CGFloat { theme.cellBorderWidth } - static var cellCornerRadius: CGFloat { theme.cellCornerRadius } - static var windowCornerRadius: CGFloat { theme.windowCornerRadius } - static var highlightBorderColor: NSColor { theme.highlightBorderColor } - static var highlightBackgroundColor: NSColor { theme.highlightBackgroundColor } - static var metaKeyCodes: [UInt16] { metaKey.keyCodes.map { UInt16($0) } } - static var metaModifierFlag: NSEvent.ModifierFlags { metaKey.modifierFlag } + static var cellBorderWidth: CGFloat { theme.themeParameters.cellBorderWidth } + static var cellCornerRadius: CGFloat { theme.themeParameters.cellCornerRadius } + static var windowCornerRadius: CGFloat { theme.themeParameters.windowCornerRadius } + static var highlightBorderColor: NSColor { theme.themeParameters.highlightBorderColor } + static var highlightBackgroundColor: NSColor { theme.themeParameters.highlightBackgroundColor } static var font: NSFont { NSFont.systemFont(ofSize: fontHeight) } static func registerDefaults() { @@ -80,9 +96,21 @@ class Preferences { } static var all: [String: Any] { defaults.persistentDomain(forName: NSRunningApplication.current.bundleIdentifier!)! } + + static func migrateOldPreferences() { + // "Main screen" was renamed to "Active screen" + if defaults.string(forKey: "showOnScreen") == "Main screen" { + defaults.removeObject(forKey: "showOnScreen") + } + } } -struct Theme { +// MacroPreference are collection of values derived from a single key +// we don't want to store every value in UserDefaults as the user could change them and contradict the macro +protocol MacroPreference { +} + +struct ThemeParameters { let label: String let cellBorderWidth: CGFloat let cellCornerRadius: CGFloat @@ -91,40 +119,76 @@ struct Theme { let highlightBackgroundColor: NSColor } -struct MetaKey { - let label: String - let keyCodes: [Int] - let modifierFlag: NSEvent.ModifierFlags +typealias LocalizedString = String + +enum AppsToShowPreference: String, CaseIterable, MacroPreference { + case all = "All apps" + case active = "Active app" + + var localizedString: LocalizedString { + switch self { + case .all: return NSLocalizedString("All apps", comment: "") + case .active: return NSLocalizedString("Active app", comment: "") + } + } } -enum ShowOnScreenPreference { - case main - case mouse +enum SpacesToShowPreference: String, CaseIterable, MacroPreference { + case all = "All spaces" + case active = "Active space" + + var localizedString: LocalizedString { + switch self { + case .all: return NSLocalizedString("All spaces", comment: "") + case .active: return NSLocalizedString("Active space", comment: "") + } + } } -enum AlignThumbnailsPreference { - case left - case center +enum ScreensToShowPreference: String, CaseIterable, MacroPreference { + case all = "All screens" + case showingAltTab = "Screen showing AltTab" + + var localizedString: LocalizedString { + switch self { + case .all: return NSLocalizedString("All screens", comment: "") + case .showingAltTab: return NSLocalizedString("Screen showing AltTab", comment: "") + } + } } -// macros are collection of values derived from a single key -// we don't want to store every value in UserDefaults as the user could change them and contradict the macro -class MacroPreferences { - static let themeList = [ - " macOS": Theme(label: " macOS", cellBorderWidth: 0, cellCornerRadius: 5, windowCornerRadius: 20, highlightBorderColor: .clear, highlightBackgroundColor: NSColor(red: 0, green: 0, blue: 0, alpha: 0.4)), - "❖ Windows 10": Theme(label: "❖ Windows 10", cellBorderWidth: 2, cellCornerRadius: 0, windowCornerRadius: 0, highlightBorderColor: .white, highlightBackgroundColor: .clear), - ] - static let metaKeyList = [ - "⌥ option": MetaKey(label: "⌥ option", keyCodes: [kVK_Option, kVK_RightOption], modifierFlag: .option), - "⌃ control": MetaKey(label: "⌃ control", keyCodes: [kVK_Control, kVK_RightControl], modifierFlag: .control), - "⌘ command": MetaKey(label: "⌘ command", keyCodes: [kVK_Command, kVK_RightCommand], modifierFlag: .command) - ] - static let showOnScreenList = [ - "Main screen": ShowOnScreenPreference.main, - "Screen including mouse": ShowOnScreenPreference.mouse, - ] - static let alignThumbnailsList = [ - "Center": AlignThumbnailsPreference.center, - "Left": AlignThumbnailsPreference.left, - ] +enum ShowOnScreenPreference: String, CaseIterable, MacroPreference { + case active = "Active screen" + case includingMouse = "Screen including mouse" + + var localizedString: LocalizedString { + switch self { + case .active: return NSLocalizedString("Active screen", comment: "") + case .includingMouse: return NSLocalizedString("Screen including mouse", comment: "") + } + } +} + +enum AlignThumbnailsPreference: String, CaseIterable, MacroPreference { + case left = "Left" + case center = "Center" + + var localizedString: LocalizedString { + switch self { + case .left: return NSLocalizedString("Left", comment: "") + case .center: return NSLocalizedString("Center", comment: "") + } + } +} + +enum ThemePreference: String, CaseIterable, MacroPreference { + case macOs = " macOS" + case windows10 = "❖ Windows 10" + + var themeParameters: ThemeParameters { + switch self { + case .macOs: return ThemeParameters(label: NSLocalizedString(" macOS", comment: ""), cellBorderWidth: 0, cellCornerRadius: 5, windowCornerRadius: 20, highlightBorderColor: .clear, highlightBackgroundColor: NSColor(red: 0, green: 0, blue: 0, alpha: 0.4)) + case .windows10: return ThemeParameters(label: NSLocalizedString("❖ Windows 10", comment: ""), cellBorderWidth: 2, cellCornerRadius: 0, windowCornerRadius: 0, highlightBorderColor: .white, highlightBackgroundColor: .clear) + } + } } diff --git a/src/logic/Screen.swift b/src/logic/Screen.swift index a0f72d4e..1ebe489e 100644 --- a/src/logic/Screen.swift +++ b/src/logic/Screen.swift @@ -3,8 +3,8 @@ import Cocoa class Screen { static func preferred() -> NSScreen { switch Preferences.showOnScreen { - case .mouse: return withMouse() ?? NSScreen.main!; // .main as fall-back - case .main: return NSScreen.main!; + case .includingMouse: return withMouse() ?? NSScreen.main!; // .main as fall-back + case .active: return NSScreen.main!; } } @@ -20,12 +20,10 @@ class Screen { window.setFrameOrigin(NSPoint(x: x, y: y)) } - static func mainUuid() -> CFString { - return "Main" as CFString - // the bellow code gets the actual main screen, but in our case we seem to be fine with sending "Main" - // our only need for this is for the System Preferences panel which has incorrect space with or without this - //let mainScreenId = NSScreen.main!.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! UInt32 - //return CFUUIDCreateString(nil, CGDisplayCreateUUIDFromDisplayID(mainScreenId).takeRetainedValue())! + static func uuid(_ screen: NSScreen) -> ScreenUuid { + let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! UInt32 + let screenUuid = CGDisplayCreateUUIDFromDisplayID(screenNumber).takeRetainedValue() + return CFUUIDCreateString(nil, screenUuid)! } } @@ -34,3 +32,5 @@ enum VerticalAlignment: CGFloat { // vertically centered but with an upward offset, similar to a book title; mimics NSView.center() case appleCentered = 0.75 } + +typealias ScreenUuid = CFString \ No newline at end of file diff --git a/src/logic/Spaces.swift b/src/logic/Spaces.swift index e8ac2737..3fed721f 100644 --- a/src/logic/Spaces.swift +++ b/src/logic/Spaces.swift @@ -5,10 +5,12 @@ class Spaces { static var currentSpaceIndex = SpaceIndex(1) static var visitedSpaces = [CGSSpaceID: Bool]() static var isSingleSpace = true + static var idsAndIndexes = allIdsAndIndexes() static func observeSpaceChanges() { NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: nil, using: { _ in debugPrint("OS event", "activeSpaceDidChangeNotification") + idsAndIndexes = allIdsAndIndexes() updateCurrentSpace() Applications.observeNewWindows() guard App.app.appIsBeingUsed else { return } @@ -16,6 +18,11 @@ class Spaces { }) } + static func refreshCurrentSpaceId() { + currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, Screen.uuid(NSScreen.main!)) + debugPrint("currentSpaceId", currentSpaceId) + } + static func initialDiscovery() { updateCurrentSpace() updateIsSingleSpace() @@ -23,8 +30,8 @@ class Spaces { } static func updateCurrentSpace() { - currentSpaceId = CGSManagedDisplayGetCurrentSpace(cgsMainConnectionId, Screen.mainUuid()) - currentSpaceIndex = allIdsAndIndexes().first { $0.0 == currentSpaceId }!.1 + refreshCurrentSpaceId() + currentSpaceIndex = idsAndIndexes.first { $0.0 == currentSpaceId }!.1 debugPrint("Current space", currentSpaceId) } @@ -35,7 +42,7 @@ class Spaces { } static func otherSpaces() -> [CGSSpaceID] { - return allIdsAndIndexes().filter { $0.0 != currentSpaceId }.map { $0.0 } + return idsAndIndexes.filter { $0.0 != currentSpaceId }.map { $0.0 } } static func windowsInSpaces(_ spaceIds: [CGSSpaceID]) -> [CGWindowID] { @@ -45,7 +52,7 @@ class Spaces { } static func updateIsSingleSpace() { - isSingleSpace = allIdsAndIndexes().count == 1 + isSingleSpace = idsAndIndexes.count == 1 } } diff --git a/src/logic/Window.swift b/src/logic/Window.swift index b3c4033f..ea8d092a 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -6,6 +6,7 @@ class Window { var title: String var thumbnail: NSImage? var icon: NSImage? + var shouldShowTheUser = true var isHidden: Bool var isMinimized: Bool var isOnAllSpaces: Bool diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index 23b1d29d..b4f00cb3 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -3,13 +3,13 @@ import Cocoa class Windows { // order in the array is important: most-recently-used elements are first static var list = [Window]() - static var previousFocusedWindowIndex = Array.Index(0) - static var focusedWindowIndex = Array.Index(0) + static var previousFocusedWindowIndex = Int(0) + static var focusedWindowIndex = Int(0) static var windowsInSubscriptionRetryLoop = [String]() - static func updateFocusedWindowIndex(_ newValue: Array.Index) { + static func updateFocusedWindowIndex(_ newIndex: Int) { previousFocusedWindowIndex = focusedWindowIndex - focusedWindowIndex = newValue + focusedWindowIndex = newIndex let focusedView = ThumbnailsView.recycledViews[focusedWindowIndex] ThumbnailsPanel.highlightCell(ThumbnailsView.recycledViews[previousFocusedWindowIndex], focusedView) App.app.thumbnailsPanel!.thumbnailsView.scrollView.contentView.scrollToVisible(focusedView.frame) @@ -19,22 +19,29 @@ class Windows { return list.count > focusedWindowIndex ? list[focusedWindowIndex] : nil } - static func cycleFocusedWindowIndex(_ step: Array.Index) { - updateFocusedWindowIndex(focusedWindowIndex + step < 0 ? list.count - 1 : (focusedWindowIndex + step) % list.count) + static func cycleFocusedWindowIndex(_ step: Int) { + var iterations = 0 + var targetIndex = focusedWindowIndex + repeat { + let next = (targetIndex + step) % list.count + targetIndex = next < 0 ? list.count + next : next + iterations += 1 + } while !list[targetIndex].shouldShowTheUser && iterations <= list.count + updateFocusedWindowIndex(targetIndex) } - static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ destroyedWindowIndex: Array.Index) { - if focusedWindowIndex <= destroyedWindowIndex { - updateFocusedWindowIndex(max(focusedWindowIndex - 1, 0)) + static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ destroyedWindowIndex: Int) { + if focusedWindowIndex >= destroyedWindowIndex { + cycleFocusedWindowIndex(-1) } } - static func moveFocusedWindowIndexAfterWindowCreatedInBackground(_ step: Int) { - updateFocusedWindowIndex(focusedWindowIndex + step) - } - static func updateSpaces() { - let spacesMap = Spaces.allIdsAndIndexes() + Spaces.updateIsSingleSpace() + // workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked, + // switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually + Spaces.refreshCurrentSpaceId() + let spacesMap = Spaces.idsAndIndexes list.forEachAsync { window in let spaceIds = window.cgWindowId.spaces() if spaceIds.count == 1 { @@ -72,6 +79,19 @@ class Windows { } } + static func refreshWhichWindowsToShowTheUser(_ screen: NSScreen) { + var screenFrameInQuartzCoordinates = screen.frame + screenFrameInQuartzCoordinates.origin.y = NSMaxY(NSScreen.screens[0].frame) - NSMaxY(screen.frame) + let activeApp = NSWorkspace.shared.frontmostApplication + Windows.list.forEach { + $0.shouldShowTheUser = !(!Preferences.showMinimizedWindows && $0.isMinimized) && + !(!Preferences.showHiddenWindows && $0.isHidden) && + !(Preferences.appsToShow == .active && $0.application.runningApplication != activeApp) && + !(Preferences.spacesToShow == .active && $0.spaceId != Spaces.currentSpaceId) && + !(Preferences.screensToShow == .showingAltTab && $0.axUiElement.position().map { p in !screenFrameInQuartzCoordinates.contains(p) } ?? true) + } + } + static func refreshAllExistingThumbnails() { refreshAllThumbnails() guard App.app.uiWorkShouldBeDone else { return } diff --git a/src/ui/App.swift b/src/ui/App.swift index dd1fe213..7b839a32 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -1,6 +1,7 @@ import Cocoa import Darwin import LetsMove +import ShortcutRecorder let cgsMainConnectionId = CGSMainConnectionID() @@ -11,6 +12,7 @@ class App: NSApplication, NSApplicationDelegate { static let repository = "https://github.com/lwouis/alt-tab-macos" static let url = URL(fileURLWithPath: Bundle.main.bundlePath) as CFURL static var app: App! + static let shortcutMonitor = LocalShortcutMonitor() var statusItem: NSStatusItem? var thumbnailsPanel: ThumbnailsPanel? var preferencesWindow: PreferencesWindow? @@ -35,6 +37,7 @@ class App: NSApplication, NSApplicationDelegate { #endif SystemPermissions.ensureAccessibilityCheckboxIsChecked() SystemPermissions.ensureScreenRecordingCheckboxIsChecked() + Preferences.migrateOldPreferences() Preferences.registerDefaults() statusItem = Menubar.make() loadMainMenuXib() @@ -71,10 +74,7 @@ class App: NSApplication, NSApplicationDelegate { func focusTarget() { debugPrint("focusTarget") - if appIsBeingUsed { - let window = Windows.focusedWindow() - focusSelectedWindow(window) - } + focusSelectedWindow(Windows.focusedWindow()) } @objc @@ -99,7 +99,9 @@ class App: NSApplication, NSApplicationDelegate { @objc func showUi() { - _ = dispatchWork { self.showUiOrCycleSelection(0) } + uiWorkShouldBeDone = true + appIsBeingUsed = true + DispatchQueue.main.async { self.showUiOrCycleSelection(0) } } func cycleSelection(_ step: Int) { @@ -120,9 +122,14 @@ class App: NSApplication, NSApplicationDelegate { func refreshOpenUi(_ windowsToRefresh: [Window]? = nil) { guard appIsBeingUsed else { return } windowsToRefresh?.forEach { $0.refreshThumbnail() } - let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) guard uiWorkShouldBeDone else { return } + // workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked, + // switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually + Spaces.refreshCurrentSpaceId() + guard uiWorkShouldBeDone else { return } + let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) thumbnailsPanel!.thumbnailsView.updateItems(currentScreen) + guard uiWorkShouldBeDone else { return } thumbnailsPanel!.setFrame(thumbnailsPanel!.thumbnailsView.frame, display: false) guard uiWorkShouldBeDone else { return } Screen.repositionPanel(thumbnailsPanel!, currentScreen, .appleCentered) @@ -130,19 +137,17 @@ class App: NSApplication, NSApplicationDelegate { func showUiOrCycleSelection(_ step: Int) { debugPrint("showUiOrCycleSelection", step) - appIsBeingUsed = true if isFirstSummon { debugPrint("showUiOrCycleSelection: isFirstSummon") isFirstSummon = false - if Windows.list.count == 0 || CGWindow.isMissionControlActive() { - appIsBeingUsed = false - isFirstSummon = true - return - } + if Windows.list.count == 0 || CGWindow.isMissionControlActive() { hideUi(); return } // TODO: find a way to update isSingleSpace by listening to space creation, instead of on every trigger - Spaces.updateIsSingleSpace() + Spaces.idsAndIndexes = Spaces.allIdsAndIndexes() // TODO: find a way to update space index when windows are moved to another space, instead of on every trigger Windows.updateSpaces() + let screen = Screen.preferred() + Windows.refreshWhichWindowsToShowTheUser(screen) + if Windows.list.first(where: { $0.shouldShowTheUser }) == nil { hideUi(); return } Windows.updateFocusedWindowIndex(0) Windows.cycleFocusedWindowIndex(step) DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay) { diff --git a/src/ui/generic-components/GridView.swift b/src/ui/generic-components/GridView.swift index dc187b56..aba8e5de 100644 --- a/src/ui/generic-components/GridView.swift +++ b/src/ui/generic-components/GridView.swift @@ -3,10 +3,13 @@ import Cocoa class GridView { static let padding = CGFloat(20) static let interPadding = CGFloat(10) + static let indentPadding = padding * 2 static func make(_ controls: [[NSView]]) -> NSGridView { let gridView = NSGridView(views: controls) + gridView.translatesAutoresizingMaskIntoConstraints = false gridView.yPlacement = .fill + gridView.rowAlignment = .firstBaseline gridView.columnSpacing = interPadding gridView.rowSpacing = interPadding gridView.column(at: 0).leadingPadding = padding diff --git a/src/ui/generic-components/StackView.swift b/src/ui/generic-components/StackView.swift new file mode 100644 index 00000000..85211d9f --- /dev/null +++ b/src/ui/generic-components/StackView.swift @@ -0,0 +1,19 @@ +import Cocoa + +class StackView: NSStackView { + var alignmentRectInsets_: NSEdgeInsets! + + convenience init(_ views: [NSView], _ orientation: NSUserInterfaceLayoutOrientation = .horizontal, top: CGFloat = 0, right: CGFloat = 0, bottom: CGFloat = 0, left: CGFloat = 0) { + self.init(views: views) + alignmentRectInsets_ = NSEdgeInsets(top: top, left: left, bottom: bottom, right: right) + alignment = orientation == .horizontal ? .firstBaseline : .left + // workaround: for some reason, horizontal stackviews with a RecorderControl have extra fittingSize.height + if orientation == .horizontal && views.first(where: { $0 is UnclearableRecorderControl }) != nil { + fit(fittingSize.width, fittingSize.height - 7) + } else { + fit() + } + } + + override var alignmentRectInsets: NSEdgeInsets { alignmentRectInsets_ } +} diff --git a/src/ui/generic-components/UnclearableRecorderControl.swift b/src/ui/generic-components/UnclearableRecorderControl.swift new file mode 100644 index 00000000..1f7bd118 --- /dev/null +++ b/src/ui/generic-components/UnclearableRecorderControl.swift @@ -0,0 +1,29 @@ +import Cocoa +import ShortcutRecorder + +class UnclearableRecorderControl: RecorderControl { + convenience init(_ shortcutString: String, _ modifierFlagsOnly: Bool) { + self.init(frame: .zero) + allowsDeleteToClearShortcutAndEndRecording = false + set(allowedModifierFlags: CocoaModifierFlagsMask, requiredModifierFlags: [], allowsEmptyModifierFlags: false) + if modifierFlagsOnly { + allowsModifierFlagsOnlyShortcut = true + } + objectValue = Shortcut(keyEquivalent: shortcutString) + // TODO: doesn't seem to work; handle width of the control better + allowsExpansionToolTips = true + widthAnchor.constraint(equalToConstant: 80).isActive = true + } + + override func drawClearButton(_ aDirtyRect: NSRect) { + // don't draw the clear button + } + + override func areModifierFlagsValid(_ aModifierFlags: NSEvent.ModifierFlags, for aKeyCode: KeyCode) -> Bool { + // only allow modifiers: ⌥ -> valid, e -> invalid, ⌥e -> invalid + if allowsModifierFlagsOnlyShortcut && aKeyCode != .none { + return false + } + return super.areModifierFlagsValid(aModifierFlags, for: aKeyCode) + } +} diff --git a/src/ui/generic-components/text/TextField.swift b/src/ui/generic-components/text/TextField.swift deleted file mode 100644 index f20ea544..00000000 --- a/src/ui/generic-components/text/TextField.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Cocoa - -class TextField: NSTextField, NSTextFieldDelegate { - var validationHandler: ((String)->Bool)? - - convenience init(_ value: String) { - self.init(string: value) - usesSingleLineMode = true - font = .labelFont(ofSize: NSFont.systemFontSize) - wantsLayer = true - layer?.borderWidth = 1 - } - - func controlTextDidChange(_ obj: Notification) { - visualizeValidationState() - let textField = obj.object as! TextField - sendAction(textField.action, to: textField.target) - } - - func visualizeValidationState() -> Void { - if !isValid() { - layer?.borderColor = NSColor.systemRed.cgColor - } else { - layer?.borderColor = .clear - } - } - - func isValid() -> Bool { - if let handler = validationHandler { - return handler(stringValue) - } - - return true - } - -} \ No newline at end of file diff --git a/src/ui/main-window/ThumbnailView.swift b/src/ui/main-window/ThumbnailView.swift index d49447b5..5c0c1507 100644 --- a/src/ui/main-window/ThumbnailView.swift +++ b/src/ui/main-window/ThumbnailView.swift @@ -102,7 +102,9 @@ class ThumbnailView: NSStackView { } override func mouseMoved(with event: NSEvent) { - mouseMovedCallback() + if Preferences.mouseHoverEnabled { + mouseMovedCallback() + } } override func mouseDown(with theEvent: NSEvent) { diff --git a/src/ui/main-window/ThumbnailsView.swift b/src/ui/main-window/ThumbnailsView.swift index e6ba66ae..2969c840 100644 --- a/src/ui/main-window/ThumbnailsView.swift +++ b/src/ui/main-window/ThumbnailsView.swift @@ -25,6 +25,8 @@ class ThumbnailsView: NSVisualEffectView { var maxY = height var newViews = [ThumbnailView]() for (index, window) in Windows.list.enumerated() { + guard App.app.uiWorkShouldBeDone else { return } + guard window.shouldShowTheUser else { continue } let view = ThumbnailsView.recycledViews[index] view.updateRecycledCellWithNewContent(window, { App.app.focusSelectedWindow(window) }, diff --git a/src/ui/preferences-window/LabelAndControl.swift b/src/ui/preferences-window/LabelAndControl.swift index 6a23b056..747dc872 100644 --- a/src/ui/preferences-window/LabelAndControl.swift +++ b/src/ui/preferences-window/LabelAndControl.swift @@ -1,27 +1,37 @@ import Cocoa +import ShortcutRecorder + +enum LabelPosition { + case leftWithSeparator + case leftWithoutSeparator + case right +} class LabelAndControl: NSObject { - static func makeLabelWithInput(_ labelText: String, _ rawName: String, _ width: CGFloat, _ suffixText: String? = nil, _ suffixUrl: String? = nil, _ validator: ((String) -> Bool)? = nil) -> [NSView] { - let input = TextField(Preferences.getString(rawName)!) - input.validationHandler = validator - input.delegate = input - input.visualizeValidationState() - input.fit(width, input.fittingSize.height) - let views = makeLabelWithProvidedControl(labelText, rawName, input) - return [views[0], NSStackView(views: [views[1], makeSuffix(rawName, suffixText!, suffixUrl)])] + static func makeLabelWithRecorder(_ labelText: String, _ rawName: String, _ shortcutString: String, _ modifierFlagsOnly: Bool = false, labelPosition: LabelPosition = .leftWithSeparator) -> [NSView] { + let input = UnclearableRecorderControl(shortcutString, modifierFlagsOnly) + let views = makeLabelWithProvidedControl(labelText, rawName, input, labelPosition: labelPosition, extraAction: GeneralTab.shortcutChangedCallback) + input.sendAction(input.action, to: input.target) + return views } - static func makeLabelWithCheckbox(_ labelText: String, _ rawName: String, extraAction: ActionClosure? = nil) -> [NSView] { - let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + static func makeLabelWithCheckbox(_ labelText: String, _ rawName: String, extraAction: ActionClosure? = nil, labelPosition: LabelPosition = .leftWithSeparator) -> [NSView] { + let checkbox = NSButton(checkboxWithTitle: labelPosition == .right ? labelText : "", target: nil, action: nil) + let views = makeLabelWithProvidedControl(labelText, rawName, checkbox, labelPosition: labelPosition, extraAction: extraAction) setControlValue(checkbox, Preferences.getString(rawName)!) - return makeLabelWithProvidedControl(labelText, rawName, checkbox, extraAction: extraAction) + checkbox.sendAction(checkbox.action, to: checkbox.target) + return views } static func makeLabelWithDropdown(_ labelText: String, _ rawName: String, _ values: [String], _ suffixText: String? = nil) -> [NSView] { + return makeLabelWithProvidedControl(labelText, rawName, makeDropDown(rawName, values), suffixText) + } + + static func makeDropDown(_ rawName: String, _ values: [String]) -> NSPopUpButton { let popUp = NSPopUpButton() popUp.addItems(withTitles: values) popUp.selectItem(withTitle: Preferences.getString(rawName)!) - return makeLabelWithProvidedControl(labelText, rawName, popUp, suffixText) + return popUp } static func makeLabelWithSlider(_ labelText: String, _ rawName: String, _ minValue: Double, _ maxValue: Double, _ numberOfTickMarks: Int, _ allowsTickMarkValuesOnly: Bool, _ unitText: String = "") -> [NSView] { @@ -34,25 +44,44 @@ class LabelAndControl: NSObject { // slider.numberOfTickMarks = numberOfTickMarks // slider.allowsTickMarkValuesOnly = allowsTickMarkValuesOnly // slider.tickMarkPosition = .below + // TODO: update suffix continuously, but only trigger action on mouse release slider.isContinuous = true return makeLabelWithProvidedControl(labelText, rawName, slider, suffixText) } - static func makeLabelWithProvidedControl(_ labelText: String?, _ rawName: String, _ control: NSControl, _ suffixText: String? = nil, _ suffixUrl: String? = nil, extraAction: ActionClosure? = nil) -> [NSView] { - let label = makeLabel(labelText, rawName) + static func makeLabelWithProvidedControl(_ labelText: String, _ rawName: String, _ control: NSControl, _ suffixText: String? = nil, _ suffixUrl: String? = nil, labelPosition: LabelPosition = .leftWithSeparator, extraAction: ActionClosure? = nil) -> [NSView] { + _ = setupControl(control, rawName, extraAction) + if labelPosition == .right && control is NSButton { + return [control] + } + let label = makeLabel(labelText, labelPosition) + if labelPosition == .right { + if let suffixText = suffixText { + return [control, label, makeSuffix(rawName, suffixText, suffixUrl)] + } + return [control, label] + } + if let suffixText = suffixText { + return [label, control, makeSuffix(rawName, suffixText, suffixUrl)] + } + return [label, control] + } + + static func setupControl(_ control: NSControl, _ rawName: String, _ extraAction: ActionClosure? = nil) -> NSControl { control.identifier = NSUserInterfaceItemIdentifier(rawName) control.onAction = { PreferencesWindow.controlWasChanged($0) extraAction?($0) } - return [label, control, suffixText != nil ? makeSuffix(rawName, suffixText!, suffixUrl) : NSView()] + return control } - private static func makeLabel(_ labelText: String?, _ rawName: String) -> NSTextField { - let label = NSTextField(wrappingLabelWithString: labelText != nil ? labelText! + ": " : "") - label.fit() + static func makeLabel(_ labelText: String, _ labelPosition: LabelPosition = .leftWithoutSeparator) -> NSTextField { + let labelText_ = labelPosition == .leftWithSeparator ? labelText + ":" : labelText + let label = NSTextField(wrappingLabelWithString: labelText_) + label.isSelectable = false + label.usesSingleLineMode = true label.alignment = .right - label.identifier = NSUserInterfaceItemIdentifier(rawName + ControlIdentifierDiscriminator.LABEL.rawValue) return label } @@ -113,6 +142,5 @@ class LabelAndControl: NSObject { } enum ControlIdentifierDiscriminator: String { - case LABEL = "_label" case SUFFIX = "_suffix" } diff --git a/src/ui/preferences-window/PreferencesWindow.swift b/src/ui/preferences-window/PreferencesWindow.swift index d8ce6fef..fe049afe 100644 --- a/src/ui/preferences-window/PreferencesWindow.swift +++ b/src/ui/preferences-window/PreferencesWindow.swift @@ -3,12 +3,26 @@ import Cocoa class PreferencesWindow: NSWindow { let tabViewController = TabViewController() - override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) { - super.init(contentRect: .zero, styleMask: style, backing: backingStoreType, defer: flag) + convenience init() { + self.init(contentRect: .zero, styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false, screen: NSScreen.main) setupWindow() setupTabViews() } + private func setupWindow() { + hidesOnDeactivate = false + isReleasedWhenClosed = false + } + + private func setupTabViews() { + contentViewController = tabViewController + tabViewController.tabStyle = .toolbar + tabViewController.addTabViewItem(GeneralTab.make()) + tabViewController.addTabViewItem(AppearanceTab.make()) + tabViewController.addTabViewItem(UpdatesTab.make()) + tabViewController.addTabViewItem(AboutTab.make()) + } + func show() { App.shared.activate(ignoringOtherApps: true) makeKeyAndOrderFront(nil) @@ -23,19 +37,4 @@ class PreferencesWindow: NSWindow { (App.shared as! App).resetPreferencesDependentComponents() } } - - private func setupWindow() { - hidesOnDeactivate = false - isReleasedWhenClosed = false - styleMask.insert([.miniaturizable, .closable]) - } - - private func setupTabViews() { - contentViewController = tabViewController - tabViewController.tabStyle = .toolbar - tabViewController.addTabViewItem(GeneralTab.make()) - tabViewController.addTabViewItem(AppearanceTab.make()) - tabViewController.addTabViewItem(UpdatesTab.make()) - tabViewController.addTabViewItem(AboutTab.make()) - } } diff --git a/src/ui/preferences-window/TabViewController.swift b/src/ui/preferences-window/TabViewController.swift index 6e434624..74ed5a18 100644 --- a/src/ui/preferences-window/TabViewController.swift +++ b/src/ui/preferences-window/TabViewController.swift @@ -9,10 +9,10 @@ class TabViewController: NSTabViewController { } private func resizeWindowToFit(_ tabViewItem: NSTabViewItem, _ window: NSWindow) { - let contentFrame = window.frameRect(forContentRect: NSRect(origin: .zero, size: tabViewItem.view!.frame.size)) - let toolbarHeight = window.frame.size.height - contentFrame.size.height - let newOrigin = NSPoint(x: window.frame.origin.x, y: window.frame.origin.y + toolbarHeight) - let newFrame = NSRect(origin: newOrigin, size: contentFrame.size) - window.setFrame(newFrame, display: false, animate: true) + let container = window.frame + let containee = tabView.subviews.first!.frame + window.setFrame(NSRect(origin: CGPoint(x: container.origin.x, y: container.origin.y + (container.height - containee.height)), + size: containee.size), + display: false, animate: true) } -} \ No newline at end of file +} diff --git a/src/ui/preferences-window/tabs/AboutTab.swift b/src/ui/preferences-window/tabs/AboutTab.swift index 98d53faa..77f74df1 100644 --- a/src/ui/preferences-window/tabs/AboutTab.swift +++ b/src/ui/preferences-window/tabs/AboutTab.swift @@ -8,33 +8,33 @@ class AboutTab: NSObject { static func makeView() -> NSGridView { let appIcon = NSImageView(image: NSImage(named: "app-icon")!.resizedCopy(150, 150)) appIcon.imageScaling = .scaleNone - let appText = NSStackView(views: [ + let appText = StackView([ BoldLabel(App.name), NSTextField(wrappingLabelWithString: NSLocalizedString("Version", comment: "") + " " + App.version), NSTextField(wrappingLabelWithString: App.licence), HyperlinkLabel(NSLocalizedString("Source code repository", comment: ""), App.repository), HyperlinkLabel(NSLocalizedString("Latest releases", comment: ""), App.repository + "/releases"), - ]) - appText.orientation = .vertical - appText.alignment = .left + ], .vertical) appText.spacing = GridView.interPadding / 2 let rowToSeparate = 3 appText.views[rowToSeparate].topAnchor.constraint(equalTo: appText.views[rowToSeparate - 1].bottomAnchor, constant: GridView.interPadding).isActive = true - let appInfo = NSStackView(views: [appIcon, appText]) + let appInfo = StackView([appIcon, appText]) appInfo.spacing = GridView.interPadding - let view = GridView.make([ + appInfo.alignment = .centerY + let sendFeedback = NSButton(title: NSLocalizedString("Send feedback…", comment: ""), target: self, action: #selector(feedbackCallback)) + let grid = GridView.make([ [appInfo], - [NSButton(title: NSLocalizedString("Send feedback…", comment: ""), target: self, action: #selector(feedbackCallback))], + [sendFeedback], ]) - let sendFeedbackCell = view.cell(atColumnIndex: 0, rowIndex: 1) + let sendFeedbackCell = grid.cell(atColumnIndex: 0, rowIndex: 1) sendFeedbackCell.xPlacement = .center sendFeedbackCell.row!.topPadding = GridView.interPadding - view.fit() - return view + grid.fit() + return grid } @objc static func feedbackCallback() { - (App.shared as! App).showFeedbackPanel() + App.app.showFeedbackPanel() } } diff --git a/src/ui/preferences-window/tabs/AppearanceTab.swift b/src/ui/preferences-window/tabs/AppearanceTab.swift index aed89b83..cfe834ed 100644 --- a/src/ui/preferences-window/tabs/AppearanceTab.swift +++ b/src/ui/preferences-window/tabs/AppearanceTab.swift @@ -8,22 +8,22 @@ class AppearanceTab { } private static func makeView() -> NSGridView { - let view = GridView.make([ - LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Theme", comment: ""), "theme", MacroPreferences.themeList.values.map { $0.label }), - LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Align windows", comment: ""), "alignThumbnails", Array(MacroPreferences.alignThumbnailsList.keys)), + let grid = GridView.make([ + LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Theme", comment: ""), "theme", ThemePreference.allCases.map { $0.themeParameters.label }), + LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Align windows", comment: ""), "alignThumbnails", AlignThumbnailsPreference.allCases.map { $0.localizedString }), LabelAndControl.makeLabelWithSlider(NSLocalizedString("Max size on screen", comment: ""), "maxScreenUsage", 10, 100, 10, true, "%"), LabelAndControl.makeLabelWithSlider(NSLocalizedString("Min windows per row", comment: ""), "minCellsPerRow", 1, 20, 20, true), LabelAndControl.makeLabelWithSlider(NSLocalizedString("Max windows per row", comment: ""), "maxCellsPerRow", 1, 40, 20, true), LabelAndControl.makeLabelWithSlider(NSLocalizedString("Rows of windows", comment: ""), "rowsCount", 1, 20, 20, true), LabelAndControl.makeLabelWithSlider(NSLocalizedString("Window app icon size", comment: ""), "iconSize", 0, 64, 11, false, "px"), LabelAndControl.makeLabelWithSlider(NSLocalizedString("Window title font size", comment: ""), "fontHeight", 0, 64, 11, false, "px"), - LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Show on", comment: ""), "showOnScreen", Array(MacroPreferences.showOnScreenList.keys)), + LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Show on", comment: ""), "showOnScreen", ShowOnScreenPreference.allCases.map { $0.localizedString }), LabelAndControl.makeLabelWithSlider(NSLocalizedString("Apparition delay", comment: ""), "windowDisplayDelay", 0, 2000, 11, false, "ms"), LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Hide space number labels", comment: ""), "hideSpaceNumberLabels"), ]) - view.column(at: 0).xPlacement = .trailing - view.rowAlignment = .lastBaseline - view.fit() - return view + grid.column(at: 0).xPlacement = .trailing + grid.rowAlignment = .lastBaseline + grid.fit() + return grid } } diff --git a/src/ui/preferences-window/tabs/GeneralTab.swift b/src/ui/preferences-window/tabs/GeneralTab.swift index 9673e548..f3eab2b6 100644 --- a/src/ui/preferences-window/tabs/GeneralTab.swift +++ b/src/ui/preferences-window/tabs/GeneralTab.swift @@ -1,43 +1,103 @@ import Cocoa +import ShortcutRecorder class GeneralTab { private static let rowHeight = CGFloat(22) // height of the "Tab key" input + static var shortcutActions = [String: ShortcutAction]() + static var shortcutsDependentOnHoldShortcut = [NSControl]() static func make() -> NSTabViewItem { return TabViewItem.make(NSLocalizedString("General", comment: ""), NSImage.preferencesGeneralName, makeView()) } - private static func makeView() -> NSGridView { - // TODO: make the validators be a part of each Preference - let tabKeyCodeValidator: ((String) -> Bool) = { - guard let int = Int($0) else { - return false - } - // non-special keys (mac & pc keyboards): https://eastmanreference.com/complete-list-of-applescript-key-codes - var whitelistedKeycodes: [Int] = Array(0...53) - whitelistedKeycodes.append(contentsOf: [65, 67, 69, 75, 76, 78, ]) - whitelistedKeycodes.append(contentsOf: Array(81...89)) - whitelistedKeycodes.append(contentsOf: [91, 92, 115, 116, 117, 119, 121]) - whitelistedKeycodes.append(contentsOf: Array(123...126)) - return whitelistedKeycodes.contains(int) - } - + private static func makeView() -> NSView { let startAtLogin = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Start at login", comment: ""), "startAtLogin", extraAction: startAtLoginCallback) - let view = GridView.make([ + var holdShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Hold", comment: ""), "holdShortcut", Preferences.holdShortcut, true, labelPosition: .leftWithoutSeparator) + holdShortcut.append(LabelAndControl.makeLabel(NSLocalizedString("then press:", comment: ""))) + let nextWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select next window", comment: ""), "nextWindowShortcut", Preferences.nextWindowShortcut, labelPosition: .right) + let previousWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select previous window", comment: ""), "previousWindowShortcut", Preferences.previousWindowShortcut, labelPosition: .right) + let cancelShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Cancel and hide", comment: ""), "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right) + let enableArrows = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Arrow keys", comment: ""), "arrowKeysEnabled", extraAction: arrowKeysEnabledCallback, labelPosition: .right) + let enableMouse = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Mouse hover", comment: ""), "mouseHoverEnabled", labelPosition: .right) + let holdAndPress = StackView(holdShortcut) + let checkboxesExplanations = LabelAndControl.makeLabel(NSLocalizedString("Select windows using:", comment: "")) + let checkboxes = StackView([StackView(enableArrows), StackView(enableMouse)], .vertical) + let appsToShow = dropdown("appsToShow", AppsToShowPreference.allCases.map { $0.localizedString }) + let spacesToShow = dropdown("spacesToShow", SpacesToShowPreference.allCases.map { $0.localizedString }) + let screensToShow = dropdown("screensToShow", ScreensToShowPreference.allCases.map { $0.localizedString }) + let showMinimizedWindows = StackView(LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Minimized", comment: ""), "showMinimizedWindows", labelPosition: .right)) + let showHiddenWindows = StackView(LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Hidden", comment: ""), "showHiddenWindows", labelPosition: .right)) + let shortcuts = StackView([nextWindowShortcut, previousWindowShortcut, cancelShortcut].map { StackView($0) }, .vertical) + let toShowDropdowns = StackView([appsToShow, spacesToShow, screensToShow], .vertical) + let toShowCheckboxes = StackView([showMinimizedWindows, showHiddenWindows], .vertical) + let toShowExplanations = LabelAndControl.makeLabel(NSLocalizedString("Show the following windows:", comment: "")) + let toShow = StackView([toShowDropdowns, toShowCheckboxes], .vertical) + + let grid = GridView.make([ startAtLogin, - LabelAndControl.makeLabelWithDropdown(NSLocalizedString("Alt key", comment: ""), "metaKey", MacroPreferences.metaKeyList.values.map { $0.label }), - LabelAndControl.makeLabelWithInput(NSLocalizedString("Tab key", comment: ""), "tabKeyCode", 33, NSLocalizedString("KeyCodes Reference", comment: ""), "https://eastmanreference.com/complete-list-of-applescript-key-codes", tabKeyCodeValidator), + [holdAndPress, shortcuts], + [checkboxesExplanations, checkboxes], + [toShowExplanations, toShow], ]) - view.column(at: 0).xPlacement = .trailing - view.rowAlignment = .lastBaseline - view.fit() - setLoginItemIfCheckboxIsOn(startAtLogin[1] as! NSButton) - return view + grid.column(at: 0).xPlacement = .trailing + [1, 2, 3].forEach { grid.row(at: $0).topPadding = GridView.interPadding } + grid.fit() + + shortcutsDependentOnHoldShortcut.append(contentsOf: [enableArrows[0] as! NSControl] + [nextWindowShortcut, previousWindowShortcut, cancelShortcut].map { $0[0] as! NSControl }) + + return grid } - private static func setLoginItemIfCheckboxIsOn(_ startAtLoginCheckbox: NSButton) { - if startAtLoginCheckbox.state == .on { - startAtLoginCallback(startAtLoginCheckbox) + private static func dropdown(_ rawName: String, _ values: [String]) -> NSControl { + let dropdown = LabelAndControl.makeDropDown(rawName, values) + return LabelAndControl.setupControl(dropdown, rawName) + } + + private static func addShortcut(_ fn: @escaping () -> Void, _ type: KeyEventType, _ shortcut: Shortcut, _ controlId: String) { + removeShortcutIfExists(controlId, type) // remove the previous shortcut + shortcutActions[controlId] = ShortcutAction(shortcut: shortcut, actionHandler: { _ in + let shortcutThatInitiatesTheApp = controlId == "previousWindowShortcut" || controlId == "nextWindowShortcut" + App.app.uiWorkShouldBeDone = shortcutThatInitiatesTheApp + if shortcutThatInitiatesTheApp { + App.app.appIsBeingUsed = true + DispatchQueue.main.async { fn() } + } else if App.app.appIsBeingUsed { + fn() + } + return true + }) + App.shortcutMonitor.addAction(shortcutActions[controlId]!, forKeyEvent: type) + } + + @objc static func shortcutChangedCallback(_ sender: NSControl) { + let controlId = sender.identifier!.rawValue + // remove the holdShortcut character in case they also use it in the other shortcuts + let newValue = (sender as! RecorderControl).stringValue.replacingOccurrences(of: Preferences.holdShortcut, with: "") + switch controlId { + case "holdShortcut": + addShortcut({ App.app.focusTarget() }, .up, Shortcut(keyEquivalent: Preferences.holdShortcut)!, controlId) + shortcutsDependentOnHoldShortcut.forEach { $0.sendAction($0.action, to: $0.target) } + case "nextWindowShortcut": addShortcut({ App.app.showUiOrCycleSelection(1) }, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + newValue)!, controlId) + case "previousWindowShortcut": addShortcut({ App.app.showUiOrCycleSelection(-1) }, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + newValue)!, controlId) + case "cancelShortcut": addShortcut({ App.app.hideUi() }, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + newValue)!, controlId) + default: return + } + } + + @objc static func arrowKeysEnabledCallback(_ sender: NSControl) { + if (sender as! NSButton).state == .on { + addShortcut({ App.app.cycleSelection(1) }, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + "→")!, "→") + addShortcut({ App.app.cycleSelection(-1) }, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + "←")!, "←") + } else { + removeShortcutIfExists("→", .down) + removeShortcutIfExists("←", .down) + } + } + + private static func removeShortcutIfExists(_ controlId: String, _ type: KeyEventType) { + if let a = shortcutActions[controlId] { + App.shortcutMonitor.removeAction(_: a, forKeyEvent: type) + shortcutActions.removeValue(forKey: controlId) } } diff --git a/src/ui/preferences-window/tabs/UpdatesTab.swift b/src/ui/preferences-window/tabs/UpdatesTab.swift index 40a4d6aa..03926fe1 100644 --- a/src/ui/preferences-window/tabs/UpdatesTab.swift +++ b/src/ui/preferences-window/tabs/UpdatesTab.swift @@ -30,17 +30,17 @@ class UpdatesTab: NSObject { policies.alignment = .left policies.orientation = .vertical policies.spacing = GridView.interPadding / 2 - let view = GridView.make([ + let grid = GridView.make([ [policyLabel, policies], [NSButton(title: NSLocalizedString("Check for updates now…", comment: ""), target: self, action: #selector(checkForUpdatesNow))], ]) - view.cell(atColumnIndex: 0, rowIndex: 0).xPlacement = .trailing - let row1 = view.row(at: 1) + grid.cell(atColumnIndex: 0, rowIndex: 0).xPlacement = .trailing + let row1 = grid.row(at: 1) row1.mergeCells(in: NSRange(location: 0, length: 2)) row1.topPadding = GridView.interPadding row1.cell(at: 0).xPlacement = .center - view.fit() - return view + grid.fit() + return grid } @objc