diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 640a2e59d..d9c13597e 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -12,11 +12,11 @@ D04BA004884A273D4D2D3EF1 /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD91161791D42FEC4A60 /* HelperExtensions.swift */; }; D04BA084CD1236EC78D90A01 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BACCBE5F97BE9B6CA645B /* Localizable.strings */; }; D04BA100BD0F47828EB649FF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BAAEC2847830A3991F8D1 /* InfoPlist.strings */; }; + 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 */; }; - D04BA1B133D53572D7B312C2 /* CollectionViewItemFontIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA1DF8CAB2FAB7FE9244B /* CollectionViewItemFontIcon.swift */; }; - D04BA1CEC6B9C8945FEC8740 /* CollectionViewItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA258B56193958D60978A /* CollectionViewItemView.swift */; }; - D04BA263682C4EA2F85B6CF0 /* QA.md in Sources */ = {isa = PBXBuildFile; fileRef = D04BACAE340252C1E910D3CE /* QA.md */; }; + D04BA1B133D53572D7B312C2 /* ThumbnailFontIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA1DF8CAB2FAB7FE9244B /* ThumbnailFontIconView.swift */; }; + D04BA1CEC6B9C8945FEC8740 /* ThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA258B56193958D60978A /* ThumbnailView.swift */; }; D04BA26A691D56031FCCF00C /* Sysctl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA8DB8AA7E5570DAC568A /* Sysctl.swift */; }; D04BA276B3241D440F65B149 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA5C2BB394F1624DD5B45 /* InfoPlist.strings */; }; D04BA2A6FF9DDDC5A1A68E36 /* Applications.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA282BB16C1554595A968 /* Applications.swift */; }; @@ -30,12 +30,10 @@ 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 /* CollectionViewItemTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1297730B191E96E7FE /* CollectionViewItemTitle.swift */; }; - D04BA6C953494839648107D1 /* CollectionViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2A4A4140AF3E09DA94D /* CollectionViewItem.swift */; }; + D04BA69D47B5E60A6AD9CBD9 /* ThumbnailTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1297730B191E96E7FE /* ThumbnailTitleView.swift */; }; D04BA737008AA2CD4E230A21 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA10777505D8A67ABD186 /* Application.swift */; }; D04BA73E90EFEF8247A5105D /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */; }; D04BA76A74267B1346D23687 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA6D57A1456C07318B8EA /* GridView.swift */; }; - D04BA76DDB00FC50D203D62C /* CollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAC2FEF7248B7BF9579E2 /* CollectionViewFlowLayout.swift */; }; D04BA775CF3F8D9394A1E256 /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA68C2561D9EE4FD851B8 /* Screen.swift */; }; D04BA7BE7F3DD24D58ACE942 /* AppearanceTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA64F1F344007EA13BA05 /* AppearanceTab.swift */; }; D04BA7F86F1926FBE31F44BF /* BaseLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA53992F116E5E704CAB3 /* BaseLabel.swift */; }; @@ -85,15 +83,14 @@ D04BA123744B0C27E9F54B05 /* codesign_sparkle_embedded_apps.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = codesign_sparkle_embedded_apps.sh; sourceTree = ""; }; D04BA1C3E42AC44CA2C5D3D8 /* app-icon.svg */ = {isa = PBXFileReference; lastKnownFileType = file.svg; path = "app-icon.svg"; sourceTree = ""; }; D04BA1D80F4EEF2A91BAD29C /* release.config.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = release.config.js; sourceTree = ""; }; - D04BA1DF8CAB2FAB7FE9244B /* CollectionViewItemFontIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemFontIcon.swift; sourceTree = ""; }; + D04BA1DF8CAB2FAB7FE9244B /* ThumbnailFontIconView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailFontIconView.swift; sourceTree = ""; }; D04BA1FC9022590D7AA02486 /* 1 window - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "1 window - 1 line.jpg"; sourceTree = ""; }; D04BA2526DC6726E0F7ACF7C /* LabelAndControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelAndControl.swift; sourceTree = ""; }; - D04BA258B56193958D60978A /* CollectionViewItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemView.swift; sourceTree = ""; }; + D04BA258B56193958D60978A /* ThumbnailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailView.swift; sourceTree = ""; }; D04BA26154AB2A2897E08CAF /* windows-theme.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "windows-theme.jpg"; sourceTree = ""; }; D04BA26C75F76C277653C932 /* FeedbackWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedbackWindow.swift; sourceTree = ""; }; D04BA27C87B86C4484A5B15B /* TabViewItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabViewItem.swift; sourceTree = ""; }; D04BA282BB16C1554595A968 /* Applications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Applications.swift; sourceTree = ""; }; - D04BA2A4A4140AF3E09DA94D /* CollectionViewItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItem.swift; sourceTree = ""; }; D04BA2A4F257F4DCE1421758 /* Podfile.lock */ = {isa = PBXFileReference; lastKnownFileType = file.lock; path = Podfile.lock; sourceTree = ""; }; D04BA2C7B51F68651B3C60E2 /* 6 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "6 windows - 1 line.jpg"; sourceTree = ""; }; D04BA32F25860B686DFE818A /* 3 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line.jpg"; sourceTree = ""; }; @@ -133,6 +130,7 @@ 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 = ""; }; + 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 = ""; }; D04BAAF760E3A8A22BDA84D6 /* appcast.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = appcast.xml; sourceTree = ""; }; @@ -142,7 +140,6 @@ D04BABFEC8F9DF41BB7A449E /* import_codesign_certificate_into_keychain.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = import_codesign_certificate_into_keychain.sh; sourceTree = ""; }; D04BAC02D60EF22D9CC7D969 /* commitlint.config.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = commitlint.config.js; sourceTree = ""; }; D04BAC159731F80FDAF4EA6C /* 1-row.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "1-row.jpg"; sourceTree = ""; }; - D04BAC2FEF7248B7BF9579E2 /* CollectionViewFlowLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewFlowLayout.swift; sourceTree = ""; }; D04BAC2FF99F629CD4ED20FC /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindow.swift; sourceTree = ""; }; D04BAC6AFC7F06D1A567F27A /* replace_environment_variables_in_app.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = replace_environment_variables_in_app.sh; sourceTree = ""; }; @@ -152,7 +149,7 @@ D04BACD976030676FD0761D5 /* Windows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = ""; }; D04BACE22DC907F03D193075 /* GeneralTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralTab.swift; sourceTree = ""; }; D04BACEE8D430B8CAAD8C4CD /* BoldLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BoldLabel.swift; sourceTree = ""; }; - D04BAD1297730B191E96E7FE /* CollectionViewItemTitle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemTitle.swift; sourceTree = ""; }; + D04BAD1297730B191E96E7FE /* ThumbnailTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailTitleView.swift; sourceTree = ""; }; D04BAD241A6928F45355B315 /* es */ = {isa = PBXFileReference; fileEncoding = 2483028224; lastKnownFileType = text.plist.strings; name = es; path = Localizable.strings; sourceTree = ""; }; D04BAD40CE2D3A8AAC3819D0 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = file.gitignore; path = .gitignore; sourceTree = ""; }; D04BAD60C97E609A759E721E /* UpdatesTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdatesTab.swift; sourceTree = ""; }; @@ -375,12 +372,11 @@ D04BA62E30174C336E4080FA /* main-window */ = { isa = PBXGroup; children = ( - D04BAC2FEF7248B7BF9579E2 /* CollectionViewFlowLayout.swift */, - D04BA2A4A4140AF3E09DA94D /* CollectionViewItem.swift */, D04BA653BD073CB58E2CFC93 /* ThumbnailsPanel.swift */, - D04BAD1297730B191E96E7FE /* CollectionViewItemTitle.swift */, - D04BA1DF8CAB2FAB7FE9244B /* CollectionViewItemFontIcon.swift */, - D04BA258B56193958D60978A /* CollectionViewItemView.swift */, + D04BAD1297730B191E96E7FE /* ThumbnailTitleView.swift */, + D04BA1DF8CAB2FAB7FE9244B /* ThumbnailFontIconView.swift */, + D04BA258B56193958D60978A /* ThumbnailView.swift */, + D04BAA998119CAA8B70A2B67 /* ThumbnailsView.swift */, ); path = "main-window"; sourceTree = ""; @@ -678,12 +674,10 @@ D04BA3CF766857381519B892 /* DispatchQueues.swift in Sources */, D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */, D04BAB68B7B8D1B548BC3AD5 /* App.swift in Sources */, - D04BA76DDB00FC50D203D62C /* CollectionViewFlowLayout.swift in Sources */, - D04BA6C953494839648107D1 /* CollectionViewItem.swift in Sources */, D04BAB048DE698E013577C51 /* ThumbnailsPanel.swift in Sources */, - D04BA69D47B5E60A6AD9CBD9 /* CollectionViewItemTitle.swift in Sources */, - D04BA1B133D53572D7B312C2 /* CollectionViewItemFontIcon.swift in Sources */, - D04BA1CEC6B9C8945FEC8740 /* CollectionViewItemView.swift in Sources */, + D04BA69D47B5E60A6AD9CBD9 /* ThumbnailTitleView.swift in Sources */, + D04BA1B133D53572D7B312C2 /* ThumbnailFontIconView.swift in Sources */, + D04BA1CEC6B9C8945FEC8740 /* ThumbnailView.swift in Sources */, D04BA3BFB0CDF4ED343914B2 /* PreferencesWindow.swift in Sources */, D04BA691CB6082A3C39CBC89 /* TabViewController.swift in Sources */, D04BA14D93726795A6937832 /* LabelAndControl.swift in Sources */, @@ -700,7 +694,7 @@ D04BAF25E67A5B31CF7676DB /* TextField.swift in Sources */, D04BAD2A7F2E8BF64EE982E9 /* TextArea.swift in Sources */, D04BA7F86F1926FBE31F44BF /* BaseLabel.swift in Sources */, - D04BA263682C4EA2F85B6CF0 /* QA.md in Sources */, + D04BA11E56383D082D7BE5A5 /* ThumbnailsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/config/base.xcconfig b/config/base.xcconfig index b41c19e80..1a3b5b9d3 100644 --- a/config/base.xcconfig +++ b/config/base.xcconfig @@ -6,7 +6,6 @@ MACOSX_DEPLOYMENT_TARGET = 10.12 SWIFT_VERSION = 4.2 INFOPLIST_FILE = Info.plist CODE_SIGN_ENTITLEMENTS = alt_tab_macos.entitlements -IDEDerivedDataPathOverride = DerivedData FRAMEWORK_SEARCH_PATHS[config=*] = $(inherited) /System/Library/PrivateFrameworks // for SkyLight.framework ENABLE_HARDENED_RUNTIME = YES // for notarization OTHER_CODE_SIGN_FLAGS = --timestamp --deep --options runtime // for notarization diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index 4f34087d7..d420bd2b5 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -74,15 +74,23 @@ extension NSObject { extension Array where Element == Window { func firstIndexThatMatches(_ element: AXUIElement) -> Self.Index? { - // `CFEqual` is safer than comparing `CGWindowID` because it will succeed even if the window is deallocated + // == is safer than comparing `CGWindowID` because it will succeed even if the window is deallocated // by the OS, in which case the `CGWindowID` will be `-1` - return firstIndex(where: { CFEqual($0.axUiElement, element) }) + return firstIndex(where: { $0.axUiElement == element }) } - func firstWindowThatMatches(_ element: AXUIElement) -> Window? { + func firstWindowThatMatches(_ element: AXUIElement) -> Element? { guard let index = firstIndexThatMatches(element) else { return nil } return self[index] } + + mutating func insertAndScaleRecycledPool(_ elements: [Element], at i: Int) { + insert(contentsOf: elements, at: i) + let neededRecycledViews = count - ThumbnailsView.recycledViews.count + if neededRecycledViews > 0 { + (1...neededRecycledViews).forEach { _ in ThumbnailsView.recycledViews.append(ThumbnailView()) } + } + } } extension NSView { @@ -164,3 +172,10 @@ extension NSImage { return img } } + +// only assign if different; useful for performance +func assignIfDifferent(_ a: UnsafeMutablePointer, _ b: T) { + if a.pointee != b { + a.pointee = b + } +} diff --git a/src/logic/Application.swift b/src/logic/Application.swift index d6d49c105..ad043c786 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -48,9 +48,9 @@ class Application: NSObject { } func observeNewWindows() { - let newWindows = axUiElement!.windows()? - .filter { $0.isActualWindow(runningApplication) && Windows.list.firstIndexThatMatches($0) == nil } ?? [] - addWindows(newWindows) + if let windows = axUiElement!.windows(), windows.count > 0 { + addWindows(windows.filter { $0.isActualWindow(runningApplication) && Windows.list.firstIndexThatMatches($0) == nil }) + } } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { @@ -59,8 +59,11 @@ class Application: NSObject { addAndObserveWindows() } - private func addWindows(_ windows: [AXUIElement]) { - Windows.list.insert(contentsOf: windows.map { Window($0, self) }, at: 0) + private func addWindows(_ axWindows: [AXUIElement]) { + let windows = axWindows.map { Window($0, self) } + Windows.list.insertAndScaleRecycledPool(windows, at: 0) + windows.forEach { _ in Windows.moveFocusedWindowIndexAfterWindowCreatedInBackground() } + (App.shared as! App).refreshOpenUi() } private func observeEvents() { @@ -101,11 +104,12 @@ private func eventApplicationActivated(_ app: App, _ element: AXUIElement) { let appFocusedWindow = element.focusedWindow(), let existingIndex = Windows.list.firstIndexThatMatches(appFocusedWindow) else { return } Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) + app.refreshOpenUi() } private func eventApplicationHiddenOrShown(_ app: App, _ element: AXUIElement, _ type: String) { for window in Windows.list { - guard CFEqual(window.application.axUiElement!, element) else { continue } + guard window.application.axUiElement! == element else { continue } window.isHidden = type == kAXApplicationHiddenNotification } app.refreshOpenUi() @@ -116,10 +120,8 @@ private func eventWindowCreated(_ app: App, _ element: AXUIElement, _ applicatio // a window being un-minimized can trigger kAXWindowCreatedNotification guard Windows.list.firstIndexThatMatches(element) == nil else { return } let window = Window(element, application) - Windows.list.insert(window, at: 0) + Windows.list.insertAndScaleRecycledPool([window], at: 0) Windows.moveFocusedWindowIndexAfterWindowCreatedInBackground() - // TODO: find a better way to get thumbnail of the new window - window.refreshThumbnail() app.refreshOpenUi() } @@ -127,4 +129,5 @@ private func eventFocusedWindowChanged(_ app: App, _ element: AXUIElement) { guard !app.appIsBeingUsed, let existingIndex = Windows.list.firstIndexThatMatches(element) else { return } Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) + app.refreshOpenUi() } \ No newline at end of file diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index 517b292f9..3b5376c97 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -56,11 +56,17 @@ class Applications { static func removeRunningApplications(_ runningApps: [NSRunningApplication]) { for runningApp in runningApps { Applications.list.removeAll(where: { $0.runningApplication.isEqual(runningApp) }) + var indexesToRemove = [Int]() + Windows.list.enumerated().forEach { (index, window) in + if window.application.runningApplication.isEqual(runningApp) { + indexesToRemove.append(index) + } + } Windows.list.removeAll(where: { $0.application.runningApplication.isEqual(runningApp) }) } guard Windows.list.count > 0 else { (App.shared as! App).hideUi(); return } // TODO: implement of more sophisticated way to decide which thumbnail gets focused on app quit - Windows.focusedWindowIndex = 1 + Windows.updateFocusedWindowIndex(1) (App.shared as! App).refreshOpenUi() } diff --git a/src/logic/Keyboard.swift b/src/logic/Keyboard.swift index 52109ed60..f55d12d5c 100644 --- a/src/logic/Keyboard.swift +++ b/src/logic/Keyboard.swift @@ -27,14 +27,20 @@ func listenToGlobalKeyboardEvents(_ app: App) { } } -func dispatchWork(_ application: App, _ uiWorkShouldBeDone: Bool, _ fn: @escaping () -> Void) -> Unmanaged? { - application.uiWorkShouldBeDone = uiWorkShouldBeDone +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() if type == .keyDown || type == .keyUp || type == .flagsChanged { @@ -46,19 +52,19 @@ func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, let isLeftArrow = event.keyCode == kVK_LeftArrow let isEscape = event.keyCode == kVK_Escape if type == .keyDown && isEscape && app.appIsBeingUsed { - return dispatchWork(app, false, { app.hideUi() }) + return dispatchWorkCurrentThread { app.hideUi() } } else if isMetaDown && type == .keyDown { if isTab && event.modifierFlags.contains(.shift) { - return dispatchWork(app, true, { app.showUiOrCycleSelection(-1) }) + return dispatchWork { app.showUiOrCycleSelection(-1) } } else if isTab { - return dispatchWork(app, true, { app.showUiOrCycleSelection(1) }) + return dispatchWork { app.showUiOrCycleSelection(1) } } else if isRightArrow && app.appIsBeingUsed { - return dispatchWork(app, true, { app.cycleSelection(1) }) + return dispatchWork { app.cycleSelection(1) } } else if isLeftArrow && app.appIsBeingUsed { - return dispatchWork(app, true, { app.cycleSelection(-1) }) + return dispatchWork { app.cycleSelection(-1) } } } else if isMetaChanged && !isMetaDown { - return dispatchWork(app, false, { app.focusTarget() }) + return dispatchWorkCurrentThread { app.focusTarget() } } } } else if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index 8c81c0d1d..c6e2513b6 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -5,11 +5,11 @@ let defaults = UserDefaults.standard class Preferences { // default values - static var defaultValues: [String : Any] = [ + static var defaultValues: [String: Any] = [ "maxScreenUsage": Float(80), "minCellsPerRow": Float(5), "maxCellsPerRow": Float(10), - "minRows": Float(3), + "rowsCount": Float(3), "iconSize": Float(32), "fontHeight": Float(15), "tabKeyCode": kVK_Tab, @@ -17,6 +17,7 @@ class Preferences { "metaKey": MacroPreferences.metaKeyList.keys.first!, "theme": MacroPreferences.themeList.keys.first!, "showOnScreen": MacroPreferences.showOnScreenList.keys.first!, + "alignThumbnails": MacroPreferences.alignThumbnailsList.keys.first!, "hideSpaceNumberLabels": false, "startAtLogin": true, ] @@ -34,7 +35,7 @@ class Preferences { static var maxScreenUsage: CGFloat { CGFloat(defaults.float(forKey: "maxScreenUsage") / 100) } static var minCellsPerRow: CGFloat { CGFloat(defaults.float(forKey: "minCellsPerRow")) } static var maxCellsPerRow: CGFloat { CGFloat(defaults.float(forKey: "maxCellsPerRow")) } - static var minRows: CGFloat { CGFloat(defaults.float(forKey: "minRows")) } + 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")) } @@ -46,6 +47,7 @@ class Preferences { 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")!]! } // derived values static var cellBorderWidth: CGFloat { theme.cellBorderWidth } @@ -100,6 +102,11 @@ enum ShowOnScreenPreference { case mouse } +enum AlignThumbnailsPreference { + case left + case center +} + // 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 { @@ -116,4 +123,8 @@ class MacroPreferences { "Main screen": ShowOnScreenPreference.main, "Screen including mouse": ShowOnScreenPreference.mouse, ] + static let alignThumbnailsList = [ + "Center": AlignThumbnailsPreference.center, + "Left": AlignThumbnailsPreference.left, + ] } diff --git a/src/logic/Window.swift b/src/logic/Window.swift index 1446d7ddd..97380062f 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -1,6 +1,7 @@ import Cocoa class Window { +// weak var itemView: CollectionViewItemView? var cgWindowId: CGWindowID var title: String var thumbnail: NSImage? @@ -19,6 +20,7 @@ class Window { kAXTitleChangedNotification, kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification, + kAXWindowResizedNotification, ] static func stopSubscriptionRetries(_ notification: String, _ cgWindowId: CGWindowID) { @@ -57,8 +59,7 @@ class Window { } func refreshThumbnail() { - guard (App.shared as! App).appIsBeingUsed, - let cgImage = cgWindowId.screenshot() else { return } + guard let cgImage = cgWindowId.screenshot() else { return } thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) } @@ -121,6 +122,7 @@ private func axObserverCallback(observer: AXObserver, element: AXUIElement, noti case kAXUIElementDestroyedNotification: eventWindowDestroyed(app, element) case kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification: eventWindowMiniaturizedOrDeminiaturized(app, element, type) case kAXTitleChangedNotification: eventWindowTitleChanged(app, element) + case kAXWindowResizedNotification: eventWindowResized(app, element) default: return } } @@ -134,20 +136,22 @@ private func eventWindowDestroyed(_ app: App, _ element: AXUIElement) { } private func eventWindowMiniaturizedOrDeminiaturized(_ app: App, _ element: AXUIElement, _ type: String) { - guard let window = Windows.list.firstWindowThatMatches(element) else { return } + guard let index = Windows.list.firstIndexThatMatches(element) else { return } + let window = Windows.list[index] window.isMinimized = type == kAXWindowMiniaturizedNotification - // TODO: find a better way to get thumbnail of the new window (when AltTab is triggered min/demin animation) - window.refreshThumbnail() app.refreshOpenUi() } private func eventWindowTitleChanged(_ app: App, _ element: AXUIElement) { - guard let window = Windows.list.firstWindowThatMatches(element), - let newTitle = window.axUiElement.title(), + guard let index = Windows.list.firstIndexThatMatches(element) else { return } + let window = Windows.list[index] + guard let newTitle = window.axUiElement.title(), newTitle != window.title else { return } window.title = newTitle - window.refreshThumbnail() app.refreshOpenUi() } - +private func eventWindowResized(_ app: App, _ element: AXUIElement) { + guard let index = Windows.list.firstIndexThatMatches(element) else { return } + app.refreshOpenUi() +} diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index 530f61c7e..70af97381 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -3,26 +3,34 @@ 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 windowsInSubscriptionRetryLoop = [String]() + static func updateFocusedWindowIndex(_ newValue: Array.Index) { + previousFocusedWindowIndex = focusedWindowIndex + focusedWindowIndex = newValue + let focusedView = ThumbnailsView.recycledViews[focusedWindowIndex] + ThumbnailsPanel.highlightCell(ThumbnailsView.recycledViews[previousFocusedWindowIndex], focusedView) + (App.shared as! App).thumbnailsPanel!.thumbnailsView.scrollView.contentView.scrollToVisible(focusedView.frame) + } + static func focusedWindow() -> Window? { return list.count > focusedWindowIndex ? list[focusedWindowIndex] : nil } static func cycleFocusedWindowIndex(_ step: Array.Index) { - focusedWindowIndex = focusedWindowIndex + step < 0 ? list.count - 1 : (focusedWindowIndex + step) % list.count + updateFocusedWindowIndex(focusedWindowIndex + step < 0 ? list.count - 1 : (focusedWindowIndex + step) % list.count) } static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ destroyedWindowIndex: Array.Index) { if focusedWindowIndex <= destroyedWindowIndex { - focusedWindowIndex -= 1 - return + updateFocusedWindowIndex(max(focusedWindowIndex - 1, 0)) } } static func moveFocusedWindowIndexAfterWindowCreatedInBackground() { - focusedWindowIndex += 1 + updateFocusedWindowIndex(focusedWindowIndex + 1) } static func updateSpaces() { diff --git a/src/ui/App.swift b/src/ui/App.swift index 5ef32d6cf..87d59cc77 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -42,6 +42,8 @@ class App: NSApplication, NSApplicationDelegate { Keyboard.listenToGlobalEvents(self) preferencesWindow = PreferencesWindow() UpdatesTab.observeUserDefaults() +// UserDefaults.standard.set(false, forKey: "NSConstraintBasedLayoutVisualizeMutuallyExclusiveConstraints") + // TODO: add warm up code for faster first launch } // keyboard shortcuts are broken without a menu. We generated the default menu from XCode and load it @@ -67,7 +69,6 @@ class App: NSApplication, NSApplicationDelegate { func focusTarget() { debugPrint("focusTarget") if appIsBeingUsed { - debugPrint("focusTarget: appIsBeingUsed") let window = Windows.focusedWindow() focusSelectedWindow(window) } @@ -95,13 +96,35 @@ class App: NSApplication, NSApplicationDelegate { @objc func showUi() { - uiWorkShouldBeDone = true - showUiOrCycleSelection(0) + _ = dispatchWork { self.showUiOrCycleSelection(0) } } func cycleSelection(_ step: Int) { Windows.cycleFocusedWindowIndex(step) - thumbnailsPanel!.highlightCell() + } + + func focusSelectedWindow(_ window: Window?) { + hideUi() + guard !CGWindow.isMissionControlActive() else { return } + window?.focus() + } + + func reopenUi() { + // TODO: retest this + thumbnailsPanel!.orderOut(nil) + Windows.refreshAllThumbnails() + refreshOpenUi() + thumbnailsPanel!.show() + } + + func refreshOpenUi() { + guard appIsBeingUsed else { return } + let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) + guard uiWorkShouldBeDone else { return } + thumbnailsPanel!.thumbnailsView.updateItems(currentScreen) + thumbnailsPanel!.setFrame(thumbnailsPanel!.thumbnailsView.frame, display: false) + guard uiWorkShouldBeDone else { return } + Screen.repositionPanel(thumbnailsPanel!, currentScreen, .appleCentered) } func showUiOrCycleSelection(_ step: Int) { @@ -119,39 +142,22 @@ class App: NSApplication, NSApplicationDelegate { Spaces.updateIsSingleSpace() // TODO: find a way to update space index when windows are moved to another space, instead of on every trigger Windows.updateSpaces() - // TODO: find a way to update thumbnails by listening to content change, instead of every trigger. Or better, switch to video - Windows.refreshAllThumbnails() - Windows.focusedWindowIndex = 0 + Windows.updateFocusedWindowIndex(0) Windows.cycleFocusedWindowIndex(step) - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay, execute: { [weak self] in - guard let self = self else { return } + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay) { + guard self.uiWorkShouldBeDone else { return } + Windows.refreshAllThumbnails() + guard self.uiWorkShouldBeDone else { return } self.refreshOpenUi() - if self.uiWorkShouldBeDone { self.thumbnailsPanel?.show() } - }) + guard self.uiWorkShouldBeDone else { return } + self.thumbnailsPanel!.show() +// DispatchQueue.main.async { +// guard self.uiWorkShouldBeDone else { return } +// self.refreshThumbnails() +// } + } } else { - debugPrint("showUiOrCycleSelection: !isFirstSummon") cycleSelection(step) } } - - func reopenUi() { - thumbnailsPanel!.orderOut(nil) - Windows.refreshAllThumbnails() - refreshOpenUi() - thumbnailsPanel!.show() - } - - func refreshOpenUi() { - guard appIsBeingUsed else { return } - let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) - if uiWorkShouldBeDone { thumbnailsPanel!.refreshCollectionView(currentScreen, uiWorkShouldBeDone); debugPrint("refreshCollectionView") } - if uiWorkShouldBeDone { thumbnailsPanel!.highlightCell(); debugPrint("highlightCellAt") } - if uiWorkShouldBeDone { Screen.repositionPanel(thumbnailsPanel!, currentScreen, .appleCentered); debugPrint("repositionPanel") } - } - - func focusSelectedWindow(_ window: Window?) { - hideUi() - guard !CGWindow.isMissionControlActive() else { return } - window?.focus() - } } diff --git a/src/ui/main-window/CollectionViewFlowLayout.swift b/src/ui/main-window/CollectionViewFlowLayout.swift deleted file mode 100644 index 92868e43d..000000000 --- a/src/ui/main-window/CollectionViewFlowLayout.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Cocoa - -class CollectionViewFlowLayout: NSCollectionViewFlowLayout { - var currentScreen: NSScreen? - var widestRow: CGFloat? - var totalHeight: CGFloat? - - override func layoutAttributesForElements(in rect: CGRect) -> [NSCollectionViewLayoutAttributes] { - let attributes_ = super.layoutAttributesForElements(in: rect) - guard !attributes_.isEmpty else { return attributes_ } - let attributes = NSArray(array: attributes_, copyItems: true) as! [NSCollectionViewLayoutAttributes] - var currentRow: [NSCollectionViewLayoutAttributes] = [] - var currentRowY = CGFloat(0) - var currentRowWidth = CGFloat(0) - var previousRowMaxHeight = CGFloat(0) - var currentRowMaxHeight = CGFloat(0) - var widestRow = CGFloat(0) - var totalHeight = CGFloat(0) - for (index, attribute) in attributes.enumerated() { - let isNewRow = abs(attribute.frame.origin.y - currentRowY) > CollectionViewItemView.height(currentScreen!) - if isNewRow { - currentRowWidth -= Preferences.interCellPadding - widestRow = max(widestRow, currentRowWidth) - setCenteredPositionForPreviousRowCells(currentRowWidth, previousRowMaxHeight, currentRow) - currentRow.removeAll() - currentRowY = attribute.frame.origin.y - currentRowWidth = 0 - previousRowMaxHeight += currentRowMaxHeight + Preferences.interCellPadding - currentRowMaxHeight = 0 - } - currentRow.append(attribute) - currentRowWidth += attribute.frame.size.width + Preferences.interCellPadding - currentRowMaxHeight = max(currentRowMaxHeight, attribute.frame.size.height) - if index == attributes.count - 1 { - currentRowWidth -= Preferences.interCellPadding - widestRow = max(widestRow, currentRowWidth) - totalHeight = previousRowMaxHeight + currentRowMaxHeight - setCenteredPositionForPreviousRowCells(currentRowWidth, previousRowMaxHeight, currentRow) - } - } - shiftCenteredElementToTheLeft(attributes, widestRow, totalHeight) - self.widestRow = widestRow - self.totalHeight = totalHeight - return attributes - } - - private func shiftCenteredElementToTheLeft(_ attributes: [NSCollectionViewLayoutAttributes], _ widestRow: CGFloat, _ totalHeight: CGFloat) { - let horizontalMargin = ((collectionView!.frame.size.width - widestRow) / 2).rounded() - for attribute in attributes { - attribute.frame.origin.x -= horizontalMargin - } - } - - private func setCenteredPositionForPreviousRowCells(_ currentRowWidth: CGFloat, _ previousRowMaxHeight: CGFloat, _ currentRow: [NSCollectionViewLayoutAttributes]) { - var marginLeft = (collectionView!.frame.size.width - currentRowWidth) / 2 - for attribute in currentRow { - attribute.frame.origin.x = marginLeft - attribute.frame.origin.y = previousRowMaxHeight - marginLeft += attribute.frame.size.width + Preferences.interCellPadding - } - } -} diff --git a/src/ui/main-window/CollectionViewItem.swift b/src/ui/main-window/CollectionViewItem.swift deleted file mode 100644 index 67f596719..000000000 --- a/src/ui/main-window/CollectionViewItem.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Cocoa -import WebKit - -class CollectionViewItem: NSCollectionViewItem { - var view_: CollectionViewItemView { view as! CollectionViewItemView } - - override func loadView() { - view = CollectionViewItemView() - view.wantsLayer = true - } - - override var isSelected: Bool { - didSet { - view.layer!.backgroundColor = isSelected ? Preferences.highlightBackgroundColor.cgColor : .clear - view.layer!.borderColor = isSelected ? Preferences.highlightBorderColor.cgColor : .clear - } - } -} diff --git a/src/ui/main-window/CollectionViewItemFontIcon.swift b/src/ui/main-window/ThumbnailFontIconView.swift similarity index 75% rename from src/ui/main-window/CollectionViewItemFontIcon.swift rename to src/ui/main-window/ThumbnailFontIconView.swift index a949d8c0f..356ef5913 100644 --- a/src/ui/main-window/CollectionViewItemFontIcon.swift +++ b/src/ui/main-window/ThumbnailFontIconView.swift @@ -2,7 +2,7 @@ import Cocoa // Font icon using SF Symbols from the SF Pro font from Apple // see https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ -class FontIcon: CellTitle { +class ThumbnailFontIconView: ThumbnailTitleView { static let sfSymbolCircledMinusSign = "􀁎" static let sfSymbolCircledDotSign = "􀍷" static let sfSymbolCircledNumber0 = "􀀸" @@ -22,19 +22,19 @@ class FontIcon: CellTitle { // number should be in the interval [0-50] func setNumber(_ number: UInt32) { let (baseCharacter, offset) = baseCharacterAndOffset(number) - string = String(UnicodeScalar(baseCharacter.unicodeScalars.first!.value + offset)!) + assignIfDifferent(&string, String(UnicodeScalar(baseCharacter.unicodeScalars.first!.value + offset)!)) } func setStar() { - string = FontIcon.sfSymbolCircledStart + assignIfDifferent(&string, ThumbnailFontIconView.sfSymbolCircledStart) } private func baseCharacterAndOffset(_ number: UInt32) -> (String, UInt32) { if number <= 9 { // numbers alternate between empty and full circles; we skip the full circles - return (FontIcon.sfSymbolCircledNumber0, number * UInt32(2)) + return (ThumbnailFontIconView.sfSymbolCircledNumber0, number * UInt32(2)) } else { - return (FontIcon.sfSymbolCircledNumber10, number - 10) + return (ThumbnailFontIconView.sfSymbolCircledNumber10, number - 10) } } } diff --git a/src/ui/main-window/CollectionViewItemTitle.swift b/src/ui/main-window/ThumbnailTitleView.swift similarity index 92% rename from src/ui/main-window/CollectionViewItemTitle.swift rename to src/ui/main-window/ThumbnailTitleView.swift index 63c631599..4affc0e1b 100644 --- a/src/ui/main-window/CollectionViewItemTitle.swift +++ b/src/ui/main-window/ThumbnailTitleView.swift @@ -1,6 +1,6 @@ import Cocoa -class CellTitle: BaseLabel { +class ThumbnailTitleView: BaseLabel { var magicOffset = CGFloat(0) convenience init(_ size: CGFloat, _ magicOffset: CGFloat = 0) { @@ -14,7 +14,7 @@ class CellTitle: BaseLabel { self.init(NSRect.zero, textContainer) self.magicOffset = magicOffset textColor = Preferences.fontColor - shadow = CollectionViewItemView.makeShadow(.darkGray) + shadow = ThumbnailView.makeShadow(.darkGray) defaultParagraphStyle = makeParagraphStyle(size) heightAnchor.constraint(equalToConstant: size + magicOffset).isActive = true } diff --git a/src/ui/main-window/CollectionViewItemView.swift b/src/ui/main-window/ThumbnailView.swift similarity index 60% rename from src/ui/main-window/CollectionViewItemView.swift rename to src/ui/main-window/ThumbnailView.swift index 632a040e6..d4319a44b 100644 --- a/src/ui/main-window/CollectionViewItemView.swift +++ b/src/ui/main-window/ThumbnailView.swift @@ -1,13 +1,13 @@ import Cocoa -class CollectionViewItemView: NSStackView { +class ThumbnailView: NSStackView { var window_: Window? var thumbnail = NSImageView() var appIcon = NSImageView() - var label = CellTitle(Preferences.fontHeight) - var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white) - var hiddenIcon = FontIcon(FontIcon.sfSymbolCircledDotSign, Preferences.fontIconSize, .white) - var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white) + var label = ThumbnailTitleView(Preferences.fontHeight) + var minimizedIcon = ThumbnailFontIconView(ThumbnailFontIconView.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white) + var hiddenIcon = ThumbnailFontIconView(ThumbnailFontIconView.sfSymbolCircledDotSign, Preferences.fontIconSize, .white) + var spaceIcon = ThumbnailFontIconView(ThumbnailFontIconView.sfSymbolCircledNumber0, Preferences.fontIconSize, .white) var mouseDownCallback: MouseDownCallback! var mouseMovedCallback: MouseMovedCallback! var dragAndDropTimer: Timer? @@ -16,12 +16,58 @@ class CollectionViewItemView: NSStackView { self.init(frame: .zero) let hStackView = makeHStackView() setupView(hStackView) - let shadow = CollectionViewItemView.makeShadow(.gray) + let shadow = ThumbnailView.makeShadow(.gray) thumbnail.shadow = shadow appIcon.shadow = shadow observeDragAndDrop() } + func updateRecycledCellWithNewContent(_ element: Window, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ newHeight: CGFloat,_ screen: NSScreen) { + window_ = element +// element.itemView = self + if thumbnail.image != element.thumbnail { + thumbnail.image = element.thumbnail + let (thumbnailWidth, thumbnailHeight) = ThumbnailView.thumbnailSize(element.thumbnail, screen) + let thumbnailSize = NSSize(width: thumbnailWidth.rounded(), height: thumbnailHeight.rounded()) + thumbnail.image?.size = thumbnailSize + thumbnail.frame.size = thumbnailSize + } + if appIcon.image != element.icon { + appIcon.image = element.icon + let appIconSize = NSSize(width: Preferences.iconSize, height: Preferences.iconSize) + appIcon.image?.size = appIconSize + appIcon.frame.size = appIconSize + } + if label.string != element.title { + label.string = element.title + // workaround: setting string on NSTextView change the font (most likely a Cocoa bug) + label.font = Preferences.font + } + assignIfDifferent(&hiddenIcon.isHidden, !window_!.isHidden) + assignIfDifferent(&minimizedIcon.isHidden, !window_!.isMinimized) + assignIfDifferent(&spaceIcon.isHidden, element.spaceIndex == nil || Spaces.isSingleSpace || Preferences.hideSpaceNumberLabels) + if !spaceIcon.isHidden { + if element.isOnAllSpaces { + spaceIcon.setStar() + } else { + spaceIcon.setNumber(UInt32(element.spaceIndex!)) + } + } + assignIfDifferent(&frame.size.width, thumbnail.frame.size.width + Preferences.intraCellPadding * 2) + assignIfDifferent(&frame.size.height, newHeight) + let fontIconWidth = CGFloat([minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.intraCellPadding) + assignIfDifferent(&label.textContainer!.size.width, frame.width - Preferences.iconSize - Preferences.intraCellPadding * 3 - fontIconWidth) + assignIfDifferent(&subviews.first!.frame.size, frame.size) + self.mouseDownCallback = mouseDownCallback + self.mouseMovedCallback = mouseMovedCallback + if trackingAreas.count == 0 { + addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil)) + } else if trackingAreas.count > 0 && trackingAreas[0].rect != bounds { + removeTrackingArea(trackingAreas[0]) + addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil)) + } + } + private func observeDragAndDrop() { // NSImageView instances are registered to drag-and-drop by default thumbnail.unregisterDraggedTypes() @@ -63,41 +109,6 @@ class CollectionViewItemView: NSStackView { mouseDownCallback() } - func updateRecycledCellWithNewContent(_ element: Window, _ mouseDownCallback: @escaping MouseDownCallback, _ mouseMovedCallback: @escaping MouseMovedCallback, _ screen: NSScreen) { - window_ = element - thumbnail.image = element.thumbnail - let (thumbnailWidth, thumbnailHeight) = CollectionViewItemView.thumbnailSize(element.thumbnail, screen) - let thumbnailSize = NSSize(width: thumbnailWidth.rounded(), height: thumbnailHeight.rounded()) - thumbnail.image?.size = thumbnailSize - thumbnail.frame.size = thumbnailSize - appIcon.image = element.icon - let appIconSize = NSSize(width: Preferences.iconSize, height: Preferences.iconSize) - appIcon.image?.size = appIconSize - appIcon.frame.size = appIconSize - label.string = element.title - // workaround: setting string on NSTextView change the font (most likely a Cocoa bug) - label.font = Preferences.font - hiddenIcon.isHidden = !window_!.isHidden - minimizedIcon.isHidden = !window_!.isMinimized - spaceIcon.isHidden = element.spaceIndex == nil || Spaces.isSingleSpace || Preferences.hideSpaceNumberLabels - if !spaceIcon.isHidden { - if element.isOnAllSpaces { - spaceIcon.setStar() - } else { - spaceIcon.setNumber(UInt32(element.spaceIndex!)) - } - } - let fontIconWidth = CGFloat([minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.intraCellPadding) - label.textContainer!.size.width = frame.width - Preferences.iconSize - Preferences.intraCellPadding * 3 - fontIconWidth - subviews.first!.frame.size = frame.size - self.mouseDownCallback = mouseDownCallback - self.mouseMovedCallback = mouseMovedCallback - if trackingAreas.count > 0 { - removeTrackingArea(trackingAreas[0]) - } - addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseMoved, .activeAlways], owner: self, userInfo: nil)) - } - static func makeShadow(_ color: NSColor) -> NSShadow { let shadow = NSShadow() shadow.shadowColor = color @@ -106,33 +117,26 @@ class CollectionViewItemView: NSStackView { return shadow } - static func downscaleFactor() -> CGFloat { - let nCellsBeforePotentialOverflow = Preferences.minRows * Preferences.minCellsPerRow - guard CGFloat(Windows.list.count) > nCellsBeforePotentialOverflow else { return 1 } - // TODO: replace this buggy heuristic with a correct implementation of downscaling - return nCellsBeforePotentialOverflow / (nCellsBeforePotentialOverflow + (sqrt(CGFloat(Windows.list.count) - nCellsBeforePotentialOverflow) * 2)) - } - static func widthMax(_ screen: NSScreen) -> CGFloat { - return (ThumbnailsPanel.widthMax(screen) / Preferences.minCellsPerRow - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor() + return ThumbnailsPanel.widthMax(screen) / Preferences.minCellsPerRow - Preferences.interCellPadding } static func widthMin(_ screen: NSScreen) -> CGFloat { - return (ThumbnailsPanel.widthMax(screen) / Preferences.maxCellsPerRow - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor() + return ThumbnailsPanel.widthMax(screen) / Preferences.maxCellsPerRow - Preferences.interCellPadding } static func height(_ screen: NSScreen) -> CGFloat { - return (ThumbnailsPanel.heightMax(screen) / Preferences.minRows - Preferences.interCellPadding) * CollectionViewItemView.downscaleFactor() + return ThumbnailsPanel.heightMax(screen) / Preferences.rowsCount - Preferences.interCellPadding } static func width(_ image: NSImage?, _ screen: NSScreen) -> CGFloat { - return max(thumbnailSize(image, screen).0 + Preferences.intraCellPadding * 2, CollectionViewItemView.widthMin(screen)) + return max(thumbnailSize(image, screen).0 + Preferences.intraCellPadding * 2, ThumbnailView.widthMin(screen)) } static func thumbnailSize(_ image: NSImage?, _ screen: NSScreen) -> (CGFloat, CGFloat) { guard let image = image else { return (0, 0) } - let thumbnailHeightMax = CollectionViewItemView.height(screen) - Preferences.intraCellPadding * 3 - Preferences.iconSize - let thumbnailWidthMax = CollectionViewItemView.widthMax(screen) - Preferences.intraCellPadding * 2 + let thumbnailHeightMax = ThumbnailView.height(screen) - Preferences.intraCellPadding * 3 - Preferences.iconSize + let thumbnailWidthMax = ThumbnailView.widthMax(screen) - Preferences.intraCellPadding * 2 let thumbnailHeight = min(image.size.height, thumbnailHeightMax) let thumbnailWidth = min(image.size.width, thumbnailWidthMax) let imageRatio = image.size.width / image.size.height diff --git a/src/ui/main-window/ThumbnailsPanel.swift b/src/ui/main-window/ThumbnailsPanel.swift index 8826bb1aa..f3711b3bf 100644 --- a/src/ui/main-window/ThumbnailsPanel.swift +++ b/src/ui/main-window/ThumbnailsPanel.swift @@ -1,15 +1,12 @@ import Cocoa -class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout { - var backgroundView: NSVisualEffectView! - var collectionView: NSCollectionView! - var app: App? +class ThumbnailsPanel: NSPanel { + var thumbnailsView = ThumbnailsView() var currentScreen: NSScreen? static let cellId = NSUserInterfaceItemIdentifier("Cell") convenience init(_ app: App) { - self.init() - self.app = app + self.init(contentRect: .zero, styleMask: .utilityWindow, backing: .buffered, defer: true) isFloatingPanel = true animationBehavior = .none hidesOnDeactivate = false @@ -17,10 +14,7 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele titleVisibility = .hidden styleMask.remove(.titled) backgroundColor = .clear - makeCollectionView() - backgroundView = ThumbnailsPanel.makeBackgroundView() - backgroundView.addSubview(collectionView) - contentView!.addSubview(backgroundView) + contentView!.addSubview(thumbnailsView) // 2nd highest level possible; this allows the app to go on top of context menus // highest level is .screenSaver but makes drag and drop on top the main window impossible level = .popUpMenu @@ -32,79 +26,11 @@ class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDele makeKeyAndOrderFront(nil) } - static func makeBackgroundView() -> NSVisualEffectView { - let backgroundView = NSVisualEffectView() - backgroundView.translatesAutoresizingMaskIntoConstraints = false - backgroundView.material = Preferences.windowMaterial - backgroundView.state = .active - backgroundView.wantsLayer = true - backgroundView.layer!.cornerRadius = Preferences.windowCornerRadius - return backgroundView - } - - func makeCollectionView() { - collectionView = NSCollectionView() - collectionView.dataSource = self - collectionView.delegate = self - collectionView.collectionViewLayout = makeLayout() - collectionView.backgroundColors = [.clear] - collectionView.isSelectable = true - collectionView.allowsMultipleSelection = false - collectionView.register(CollectionViewItem.self, forItemWithIdentifier: ThumbnailsPanel.cellId) - } - - private func makeLayout() -> CollectionViewFlowLayout { - let layout = CollectionViewFlowLayout() - layout.minimumInteritemSpacing = Preferences.interCellPadding - layout.minimumLineSpacing = Preferences.interCellPadding - return layout - } - - func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { - return Windows.list.count - } - - func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { - let item = collectionView.makeItem(withIdentifier: ThumbnailsPanel.cellId, for: indexPath) as! CollectionViewItem - item.view_.updateRecycledCellWithNewContent(Windows.list[indexPath.item], - { self.app!.focusSelectedWindow(item.view_.window_) }, - { self.app!.thumbnailsPanel!.highlightCell(item) }, - currentScreen!) - return item - } - - func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize { - guard indexPath.item < Windows.list.count else { return .zero } - return NSSize(width: CollectionViewItemView.width(Windows.list[indexPath.item].thumbnail, currentScreen!).rounded(), height: CollectionViewItemView.height(currentScreen!).rounded()) - } - - func highlightCell() { - collectionView.deselectAll(nil) - collectionView.selectItems(at: [IndexPath(item: Windows.focusedWindowIndex, section: 0)], scrollPosition: .top) - } - - func highlightCell(_ cell: CollectionViewItem) { - let newIndex = collectionView.indexPath(for: cell)! - if Windows.focusedWindowIndex != newIndex.item { - collectionView.selectItems(at: [newIndex], scrollPosition: .top) - collectionView.deselectItems(at: [IndexPath(item: Windows.focusedWindowIndex, section: 0)]) - Windows.focusedWindowIndex = newIndex.item - } - } - - func refreshCollectionView(_ screen: NSScreen, _ uiWorkShouldBeDone: Bool) { - if uiWorkShouldBeDone { self.currentScreen = screen } - let layout = collectionView.collectionViewLayout as! CollectionViewFlowLayout - if uiWorkShouldBeDone { layout.currentScreen = screen } - if uiWorkShouldBeDone { layout.invalidateLayout() } - if uiWorkShouldBeDone { collectionView.setFrameSize(NSSize(width: ThumbnailsPanel.widthMax(screen).rounded(), height: ThumbnailsPanel.heightMax(screen).rounded())) } - if uiWorkShouldBeDone { collectionView.reloadData() } - if uiWorkShouldBeDone { collectionView.layoutSubtreeIfNeeded() } - if uiWorkShouldBeDone { collectionView.setFrameSize(NSSize(width: layout.widestRow!, height: layout.totalHeight!)) } - let windowSize = NSSize(width: layout.widestRow! + Preferences.windowPadding * 2, height: layout.totalHeight! + Preferences.windowPadding * 2) - if uiWorkShouldBeDone { setContentSize(windowSize) } - if uiWorkShouldBeDone { backgroundView!.setFrameSize(windowSize) } - if uiWorkShouldBeDone { collectionView.setFrameOrigin(NSPoint(x: Preferences.windowPadding, y: Preferences.windowPadding)) } + static func highlightCell(_ previousView: NSView, _ newView: NSView) { + previousView.layer!.backgroundColor = .clear + previousView.layer!.borderColor = .clear + newView.layer!.backgroundColor = Preferences.highlightBackgroundColor.cgColor + newView.layer!.borderColor = Preferences.highlightBorderColor.cgColor } static func widthMax(_ screen: NSScreen) -> CGFloat { diff --git a/src/ui/main-window/ThumbnailsView.swift b/src/ui/main-window/ThumbnailsView.swift new file mode 100644 index 000000000..a2134eb34 --- /dev/null +++ b/src/ui/main-window/ThumbnailsView.swift @@ -0,0 +1,104 @@ +import Cocoa + +class ThumbnailsView: NSVisualEffectView { + let scrollView = ScrollView() + static var recycledViews = [ThumbnailView]() + + convenience init() { + self.init(frame: .zero) + material = Preferences.windowMaterial + state = .active + wantsLayer = true + layer!.cornerRadius = Preferences.windowCornerRadius + addSubview(scrollView) + // TODO: think about this optimization more + (1...100).forEach { _ in ThumbnailsView.recycledViews.append(ThumbnailView()) } + } + + func updateItems(_ screen: NSScreen) { + let widthMax = ThumbnailsPanel.widthMax(screen).rounded() + let heightMax = ThumbnailsPanel.heightMax(screen).rounded() + let height = ThumbnailView.height(screen).rounded(.down) + var currentX = CGFloat(0) + var currentY = CGFloat(0) + var maxX = CGFloat(0) + var maxY = height + var newViews = [ThumbnailView]() + for (index, window) in Windows.list.enumerated() { + let view = ThumbnailsView.recycledViews[index] + view.updateRecycledCellWithNewContent(window, + { (App.shared as! App).focusSelectedWindow(window) }, + { Windows.updateFocusedWindowIndex(index) }, + height, screen) + let width = view.frame.size.width + if (currentX + Preferences.interCellPadding + width).rounded(.down) > widthMax { + currentX = CGFloat(0) + currentY = (currentY + Preferences.interCellPadding + height).rounded(.down) + maxY = max(currentY + height, maxY) + } else { + maxX = max(currentX + width, maxX) + } + view.frame.origin = CGPoint(x: currentX, y: currentY) + currentX = (currentX + Preferences.interCellPadding + width).rounded(.down) + newViews.append(view) + } + scrollView.documentView!.subviews = newViews + frame.size = NSSize(width: min(maxX, widthMax) + Preferences.windowPadding * 2, height: min(maxY, heightMax) + Preferences.windowPadding * 2) + scrollView.frame.size = NSSize(width: min(maxX, widthMax), height: min(maxY, heightMax)) + scrollView.frame.origin = CGPoint(x: Preferences.windowPadding, y: Preferences.windowPadding) + scrollView.contentView.frame.size = scrollView.frame.size + scrollView.documentView!.frame.size = NSSize(width: maxX, height: maxY) + if Preferences.alignThumbnails == .center { + centerRows(maxX) + } + } + + func centerRows(_ maxX: CGFloat) { + var rowStartIndex = 0 + var rowWidth = CGFloat(0) + var rowY = CGFloat(0) + for (index, window) in Windows.list.enumerated() { + let view = ThumbnailsView.recycledViews[index] + if view.frame.origin.y == rowY { + rowWidth += Preferences.interCellPadding + view.frame.size.width + } else { + if rowStartIndex == 0 { + rowWidth -= Preferences.interCellPadding // first row has 1 extra padding + } + shiftRow(maxX, rowWidth, rowStartIndex, index) + rowStartIndex = index + rowWidth = view.frame.size.width + rowY = view.frame.origin.y + } + } + shiftRow(maxX, rowWidth, rowStartIndex, Windows.list.count) + } + + private func shiftRow(_ maxX: CGFloat, _ rowWidth: CGFloat, _ rowStartIndex: Int, _ index: Int) { + let offset = ((maxX - rowWidth) / 2).rounded() + if offset > 0 { + (rowStartIndex.. 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)), 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("Min rows of windows", comment: ""), "minRows", 1, 20, 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)), diff --git a/src/ui/preferences-window/tabs/GeneralTab.swift b/src/ui/preferences-window/tabs/GeneralTab.swift index 5a58e96ce..50dc42405 100644 --- a/src/ui/preferences-window/tabs/GeneralTab.swift +++ b/src/ui/preferences-window/tabs/GeneralTab.swift @@ -50,7 +50,7 @@ class GeneralTab { LSSharedFileListInsertItemURL(loginItems, kLSSharedFileListItemBeforeFirst.takeRetainedValue(), nil, nil, App.url, nil, nil) } else { loginItemsSnapshot.forEach { - if CFEqual(LSSharedFileListItemCopyResolvedURL($0, 0, nil).takeRetainedValue(), App.url) { + if LSSharedFileListItemCopyResolvedURL($0, 0, nil).takeRetainedValue() == App.url { LSSharedFileListItemRemove(loginItems, $0) } }