feat: faster ui (closes #171, closes #128, closes #89)

* Replaced the NSCollectionView with a custom NSView. Benefit includes simpler code, and control over recycling logic.
* In cases where the user has many open windows, replaced the downscaling strategy with a scrollable view
* Added a preference: align thumbnails to the left or center
This commit is contained in:
Louis Pontoise authored and lwouis committed 2020-03-24 19:37:45 +09:00
1 parent 2008025302
commit 311beef3cf
19 files changed
+326 -319

No files matched your search

+16 -22
View File
@@ -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 = "<group>"; };
D04BA1C3E42AC44CA2C5D3D8 /* app-icon.svg */ = {isa = PBXFileReference; lastKnownFileType = file.svg; path = "app-icon.svg"; sourceTree = "<group>"; };
D04BA1D80F4EEF2A91BAD29C /* release.config.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = release.config.js; sourceTree = "<group>"; };
D04BA1DF8CAB2FAB7FE9244B /* CollectionViewItemFontIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemFontIcon.swift; sourceTree = "<group>"; };
D04BA1DF8CAB2FAB7FE9244B /* ThumbnailFontIconView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailFontIconView.swift; sourceTree = "<group>"; };
D04BA1FC9022590D7AA02486 /* 1 window - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "1 window - 1 line.jpg"; sourceTree = "<group>"; };
D04BA2526DC6726E0F7ACF7C /* LabelAndControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelAndControl.swift; sourceTree = "<group>"; };
D04BA258B56193958D60978A /* CollectionViewItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemView.swift; sourceTree = "<group>"; };
D04BA258B56193958D60978A /* ThumbnailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailView.swift; sourceTree = "<group>"; };
D04BA26154AB2A2897E08CAF /* windows-theme.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "windows-theme.jpg"; sourceTree = "<group>"; };
D04BA26C75F76C277653C932 /* FeedbackWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedbackWindow.swift; sourceTree = "<group>"; };
D04BA27C87B86C4484A5B15B /* TabViewItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabViewItem.swift; sourceTree = "<group>"; };
D04BA282BB16C1554595A968 /* Applications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Applications.swift; sourceTree = "<group>"; };
D04BA2A4A4140AF3E09DA94D /* CollectionViewItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItem.swift; sourceTree = "<group>"; };
D04BA2A4F257F4DCE1421758 /* Podfile.lock */ = {isa = PBXFileReference; lastKnownFileType = file.lock; path = Podfile.lock; sourceTree = "<group>"; };
D04BA2C7B51F68651B3C60E2 /* 6 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "6 windows - 1 line.jpg"; sourceTree = "<group>"; };
D04BA32F25860B686DFE818A /* 3 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line.jpg"; sourceTree = "<group>"; };
@@ -133,6 +130,7 @@
D04BA9B93823398A542FF7A0 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D04BA9EF65B2E7AF9E3ADCA3 /* 2 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2 windows - 1 line.jpg"; sourceTree = "<group>"; };
D04BAA34E0CB00DED7C04B4F /* 2-rows.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "2-rows.jpg"; sourceTree = "<group>"; };
D04BAA998119CAA8B70A2B67 /* ThumbnailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsView.swift; sourceTree = "<group>"; };
D04BAA9E0539EE620D08F63F /* fr */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = fr; path = InfoPlist.strings; sourceTree = "<group>"; };
D04BAAB92261FC04854FDDE9 /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
D04BAAF760E3A8A22BDA84D6 /* appcast.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = appcast.xml; sourceTree = "<group>"; };
@@ -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 = "<group>"; };
D04BAC02D60EF22D9CC7D969 /* commitlint.config.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = commitlint.config.js; sourceTree = "<group>"; };
D04BAC159731F80FDAF4EA6C /* 1-row.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "1-row.jpg"; sourceTree = "<group>"; };
D04BAC2FEF7248B7BF9579E2 /* CollectionViewFlowLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewFlowLayout.swift; sourceTree = "<group>"; };
D04BAC2FF99F629CD4ED20FC /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGWindow.swift; sourceTree = "<group>"; };
D04BAC6AFC7F06D1A567F27A /* replace_environment_variables_in_app.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = replace_environment_variables_in_app.sh; sourceTree = "<group>"; };
@@ -152,7 +149,7 @@
D04BACD976030676FD0761D5 /* Windows.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = "<group>"; };
D04BACE22DC907F03D193075 /* GeneralTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralTab.swift; sourceTree = "<group>"; };
D04BACEE8D430B8CAAD8C4CD /* BoldLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BoldLabel.swift; sourceTree = "<group>"; };
D04BAD1297730B191E96E7FE /* CollectionViewItemTitle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewItemTitle.swift; sourceTree = "<group>"; };
D04BAD1297730B191E96E7FE /* ThumbnailTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailTitleView.swift; sourceTree = "<group>"; };
D04BAD241A6928F45355B315 /* es */ = {isa = PBXFileReference; fileEncoding = 2483028224; lastKnownFileType = text.plist.strings; name = es; path = Localizable.strings; sourceTree = "<group>"; };
D04BAD40CE2D3A8AAC3819D0 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = file.gitignore; path = .gitignore; sourceTree = "<group>"; };
D04BAD60C97E609A759E721E /* UpdatesTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdatesTab.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
@@ -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;
};
-1
View File
@@ -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
+18 -3
View File
@@ -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<T: Equatable>(_ a: UnsafeMutablePointer<T>, _ b: T) {
if a.pointee != b {
a.pointee = b
}
}
+12 -9
View File
@@ -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()
}
+7 -1
View File
@@ -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()
}
+14 -8
View File
@@ -27,14 +27,20 @@ func listenToGlobalKeyboardEvents(_ app: App) {
}
}
func dispatchWork(_ application: App, _ uiWorkShouldBeDone: Bool, _ fn: @escaping () -> Void) -> Unmanaged<CGEvent>? {
application.uiWorkShouldBeDone = uiWorkShouldBeDone
func dispatchWork(_ fn: @escaping () -> Void) -> Unmanaged<CGEvent>? {
(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<CGEvent>? {
(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<CGEvent>? {
let app = Unmanaged<App>.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 {
+14 -3
View File
@@ -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,
]
}
+13 -9
View File
@@ -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()
}
+12 -4
View File
@@ -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<Window>.Index(0)
static var focusedWindowIndex = Array<Window>.Index(0)
static var windowsInSubscriptionRetryLoop = [String]()
static func updateFocusedWindowIndex(_ newValue: Array<Window>.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<Window>.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<Window>.Index) {
if focusedWindowIndex <= destroyedWindowIndex {
focusedWindowIndex -= 1
return
updateFocusedWindowIndex(max(focusedWindowIndex - 1, 0))
}
}
static func moveFocusedWindowIndexAfterWindowCreatedInBackground() {
focusedWindowIndex += 1
updateFocusedWindowIndex(focusedWindowIndex + 1)
}
static func updateSpaces() {
+39 -33
View File
@@ -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()
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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)
}
}
}
@@ -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
}
@@ -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
+9 -83
View File
@@ -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 {
+104
View File
@@ -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..<index).forEach {
ThumbnailsView.recycledViews[$0].frame.origin.x += offset
}
}
}
}
class ScrollView: NSScrollView {
convenience init() {
self.init(frame: .zero)
documentView = FlippedView(frame: .zero)
drawsBackground = false
hasVerticalScroller = true
}
override func tile() {
super.tile()
// draw the scroller on top of the content
contentView.frame = bounds
}
}
class FlippedView: NSView {
override var isFlipped: Bool { true }
}
@@ -10,10 +10,11 @@ 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)),
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)),
@@ -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)
}
}