diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 2195b2d0..1453b7bd 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ BF0C812C068CD751CE9BFB67 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8D5993267A74D4875C1A /* Localizable.strings */; }; BF0C81480366BAD71BC9A158 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8ABA423E72218605CA4F /* InfoPlist.strings */; }; BF0C816911CAD3F1B5865BFA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C82DF98B6161C45361197 /* Localizable.strings */; }; + BF0C81A1933D59E1063E350D /* 20MB-photo.jpg in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8E1C49845D813673D7A2 /* 20MB-photo.jpg */; }; BF0C81A79128CF877FE895CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8A48BBB3BF649864DB08 /* InfoPlist.strings */; }; BF0C81B64A1205F066AC796A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C80404D3455D496C02F29 /* Localizable.strings */; }; BF0C81D836A8A4CA393B135F /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C82E55127D38ABF969CA0 /* InfoPlist.strings */; }; @@ -105,6 +106,7 @@ BF0C8406805BDEC5B69E8803 /* screen-recording@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8E25373671B528AD448D /* screen-recording@2x.png */; }; BF0C84D7DC2AC65BB1C111EB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8349BA94516E8760BAFA /* Localizable.strings */; }; BF0C84E5AC1D423625ABF3E6 /* Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C829EAA93A4C4ADB42CF8 /* Mocks.swift */; }; + BF0C8521DA3078516615C414 /* HelperExtensionsTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */; }; BF0C852D28E458CDD6FA3E44 /* app.icns in Resources */ = {isa = PBXBuildFile; fileRef = BF0C84E11784B3B0900D0963 /* app.icns */; }; BF0C853B271C4407E87FA183 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8096A88BC18C4902C037 /* InfoPlist.strings */; }; BF0C8571D70170B73B46A262 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C86631FA950CE81FB8542 /* Localizable.strings */; }; @@ -118,6 +120,7 @@ BF0C863526C260F4155F9D2C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C852BFEEFB55B6E4EF18D /* Localizable.strings */; }; BF0C8658CC748011E57691B7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C84F4B25A7C26A88ADE92 /* Localizable.strings */; }; BF0C865E3C3F3B353A7A7CBC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C86FFF1C801761B7CE363 /* Localizable.strings */; }; + BF0C866DE5D740AF7756CDCF /* ImageScalingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C854910CCD4FB493C3A5D /* ImageScalingTests.swift */; }; BF0C86BB79E417163E813179 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C80851C26EDB3DB98556C /* InfoPlist.strings */; }; BF0C875A28B98904F8F4A66A /* ATShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8BA452332236D972C60E /* ATShortcut.swift */; }; BF0C87732A6E660899D13BFD /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C886B1D6E707E9E6BE46C /* InfoPlist.strings */; }; @@ -133,6 +136,7 @@ BF0C896B5D08B7DA8F2024AC /* KeyRepeatTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8D606B53D315F2D9927B /* KeyRepeatTimer.swift */; }; BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */; }; BF0C898511686611E4D7D81E /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8B2C057099EDABE46779 /* Button.swift */; }; + BF0C8991399A3277A9A02FFA /* LightImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */; }; BF0C89B4B17D7843C32A102E /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8353097840D7DB1E0E2E /* Markdown.swift */; }; BF0C89C49CFBAAB4646BB8CB /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C82A8848224E76B880A83 /* InfoPlist.strings */; }; BF0C89F0632E0A2EC52D9168 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C87749F1A64E2D3305181 /* InfoPlist.strings */; }; @@ -168,6 +172,7 @@ BF0C8EA4654F8059F0AED40E /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C82992C31AA542EF9E638 /* InfoPlist.strings */; }; BF0C8EF58A07D9752A6392FC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8D928E2ABECB92738113 /* Localizable.strings */; }; BF0C8F3526BC393290D5BE59 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C81EC1C5F3052A75C7D7B /* InfoPlist.strings */; }; + BF0C8F9B8014AD21E68B1349 /* HelperExtensionsTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */; }; BF0C8FF2A4B00D409494EA80 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C88D79FA0C773D94F351A /* InfoPlist.strings */; }; D04BA004884A273D4D2D3EF1 /* HelperExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD91161791D42FEC4A60 /* HelperExtensions.swift */; }; D04BA0182F4734A5C8BB63DC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BA5461DAEE9B5350C3020 /* Localizable.strings */; }; @@ -347,6 +352,7 @@ BF0C809C9DE36EA1317816FB /* nn */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = nn; path = Localizable.strings; sourceTree = ""; }; BF0C80A05C11C8ADC366EC4A /* lb */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = lb; path = InfoPlist.strings; sourceTree = ""; }; BF0C80CD087C6384E0340F4E /* convert_font_to_png.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = convert_font_to_png.sh; sourceTree = ""; }; + BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HelperExtensionsTestable.swift; sourceTree = ""; }; BF0C8113492C0FFCB4454BC9 /* sl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sl; path = Localizable.strings; sourceTree = ""; }; BF0C8118F91A00A04AD4DE26 /* bg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = bg; path = InfoPlist.strings; sourceTree = ""; }; BF0C813DE8B55A735B1720B5 /* subset_font.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = subset_font.sh; sourceTree = ""; }; @@ -388,6 +394,7 @@ BF0C84E11784B3B0900D0963 /* app.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = app.icns; sourceTree = ""; }; BF0C850C46CCD8B978168CCD /* sv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sv; path = Localizable.strings; sourceTree = ""; }; BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AXUIElementTests.swift; sourceTree = ""; }; + BF0C854910CCD4FB493C3A5D /* ImageScalingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageScalingTests.swift; sourceTree = ""; }; BF0C85C20516C4210508F349 /* tsconfig.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tsconfig.json; sourceTree = ""; }; BF0C861255F42BF7B23408EF /* sq */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sq; path = InfoPlist.strings; sourceTree = ""; }; BF0C863EDD60D925950B223B /* convert_iconset_to_icns.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = convert_iconset_to_icns.sh; sourceTree = ""; }; @@ -473,11 +480,13 @@ BF0C8DF008F1EB5F23291A13 /* SpacesEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpacesEvents.swift; sourceTree = ""; }; BF0C8DF173A246A0E0C30649 /* override_tags_on_poeditor.ts */ = {isa = PBXFileReference; lastKnownFileType = file.ts; path = override_tags_on_poeditor.ts; sourceTree = ""; }; BF0C8DF400A1AF64EB0CD0B3 /* TrafficLightButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrafficLightButton.swift; sourceTree = ""; }; + BF0C8E1C49845D813673D7A2 /* 20MB-photo.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "20MB-photo.jpg"; sourceTree = ""; }; BF0C8E1FBCBC7B64C69D933C /* titles-style-full.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "titles-style-full.jpg"; sourceTree = ""; }; BF0C8E25373671B528AD448D /* screen-recording@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "screen-recording@2x.png"; sourceTree = ""; }; BF0C8E478D68ED85DAE6BF43 /* ga */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ga; path = InfoPlist.strings; sourceTree = ""; }; BF0C8E54658B4DAEA0528200 /* menubar-2@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-2@2x.png"; sourceTree = ""; }; BF0C8E5D2BD5F7F93DF77DF5 /* sk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sk; path = InfoPlist.strings; sourceTree = ""; }; + BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightImageView.swift; sourceTree = ""; }; BF0C8E7B087DCC97A454C0AE /* uk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = uk; path = InfoPlist.strings; sourceTree = ""; }; BF0C8EAF02309B9C489E65F4 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; BF0C8EC400A560FDF99F8C35 /* el */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = el; path = Localizable.strings; sourceTree = ""; }; @@ -975,6 +984,8 @@ BF0C829EAA93A4C4ADB42CF8 /* Mocks.swift */, BF0C8C6810FF50908E637A48 /* ConcurrentScreenshots.swift */, BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */, + BF0C854910CCD4FB493C3A5D /* ImageScalingTests.swift */, + BF0C8AE65DDFD6359BE12CB3 /* resources */, ); path = "unit-tests"; sourceTree = ""; @@ -1041,6 +1052,14 @@ path = ca.lproj; sourceTree = ""; }; + BF0C8AE65DDFD6359BE12CB3 /* resources */ = { + isa = PBXGroup; + children = ( + BF0C8E1C49845D813673D7A2 /* 20MB-photo.jpg */, + ); + path = resources; + sourceTree = ""; + }; BF0C8B1DA0DFC696489608FF /* bg.lproj */ = { isa = PBXGroup; children = ( @@ -1344,6 +1363,7 @@ 1C96189273B7482124C36F4C /* Switch.swift */, 1C9617C5839903D752070CF2 /* ImageTextButtonView.swift */, BF0C896FD4907CD85D62C124 /* WindowlessAppIndicator.swift */, + BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */, ); path = "generic-components"; sourceTree = ""; @@ -1437,6 +1457,7 @@ BF0C8B841E890CA0D68FFD86 /* Logger.swift */, BF0C8353097840D7DB1E0E2E /* Markdown.swift */, BF0C88D101728C6798C7FC07 /* private-apis */, + BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */, ); path = "api-wrappers"; sourceTree = ""; @@ -1950,6 +1971,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + BF0C81A1933D59E1063E350D /* 20MB-photo.jpg in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2246,6 +2268,8 @@ BF0C84E5AC1D423625ABF3E6 /* Mocks.swift in Sources */, BF0C8CBED8D88DFBADE7EC03 /* ConcurrentScreenshots.swift in Sources */, BF0C88FFEE7645A1966C7C4E /* AXUIElementTests.swift in Sources */, + BF0C866DE5D740AF7756CDCF /* ImageScalingTests.swift in Sources */, + BF0C8F9B8014AD21E68B1349 /* HelperExtensionsTestable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2337,6 +2361,8 @@ BF0C803A13C409E7F01FBCAB /* SkyLight.framework.swift in Sources */, BF0C80C69EFEF93C18539925 /* PreferencesMigrations.swift in Sources */, BF0C8973EC078169170F2634 /* CliEvents.swift in Sources */, + BF0C8521DA3078516615C414 /* HelperExtensionsTestable.swift in Sources */, + BF0C8991399A3277A9A02FFA /* LightImageView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index f5b8b929..fce083e7 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -193,13 +193,6 @@ extension DispatchQoS { } } -extension NSImageView { - func setSize(_ size: NSSize) { - image?.size = size - frame.size = size - } -} - extension NSTextField { func setWidth(_ width: CGFloat) { frame.size.width = width @@ -210,36 +203,28 @@ extension NSTextField { } extension NSImage { - /// if an image can be used in different contexts (e.g. multiple NSTextViews), it's safer to copy it - /// see https://www.noodlesoft.com/blog/2011/04/15/the-proper-care-and-feeding-of-nsimage/ - func copyToSeparateContexts() -> NSImage { - return copy() as! NSImage + func cgImage(maxSize: NSSize) -> CGImage { + // some images like NSRunningApp.icon are from icns. They hosts multiple representations and it's hard to know the highest resolution + // by setting a maxSize, the returned CGImage will be the biggest it can under that maxSize + var rect = NSRect(origin: .zero, size: maxSize) + return cgImage(forProposedRect: &rect, context: nil, hints: nil)! } // NSImage(named) caches/reuses NSImage objects; we force separate instances of images by using copy() static func initCopy(_ name: String) -> NSImage { return NSImage(named: name)!.copy() as! NSImage } +} - static func initTemplateCopy(_ name: String) -> NSImage { - let image = initCopy(name) - image.isTemplate = true - return image +extension CGImage { + func nsImage() -> NSImage { + return NSImage(cgImage: self, size: NSSize(width: width, height: height)) } - // copy and resize an image using high quality interpolation - static func initResizedCopy(_ name: String, _ width: CGFloat, _ height: CGFloat) -> NSImage { - let original = initCopy(name) - let img = NSImage(size: CGSize(width: width, height: height)) - img.lockFocus() - NSGraphicsContext.current?.imageInterpolation = .high - original.draw(in: NSMakeRect(0, 0, width, height), from: NSMakeRect(0, 0, original.size.width, original.size.height), operation: .copy, fraction: 1) - img.unlockFocus() - return img - } - - static func fromCgImage(_ cgImage: CGImage) -> NSImage { - return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + static func named(_ imageName: String) -> CGImage { + let imageURL = Bundle.main.url(forResource: imageName, withExtension: nil)! + let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, nil)! + return CGImageSourceCreateImageAtIndex(imageSource, 0, nil)! } } diff --git a/src/api-wrappers/HelperExtensionsTestable.swift b/src/api-wrappers/HelperExtensionsTestable.swift new file mode 100644 index 00000000..60ff3c3b --- /dev/null +++ b/src/api-wrappers/HelperExtensionsTestable.swift @@ -0,0 +1,20 @@ +import Cocoa + +extension CGImage { + func resizedCopyWithCoreGraphics(_ newSize: NSSize, _ fixBitmapInfo: Bool) -> CGImage { + let finalBitmapInfo = fixBitmapInfo + ? CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue).union(.byteOrder32Little) + : bitmapInfo + let context = CGContext(data: nil, + width: Int(newSize.width), + height: Int(newSize.height), + bitsPerComponent: bitsPerComponent, + bytesPerRow: 0, + space: colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: finalBitmapInfo.rawValue + )! + context.interpolationQuality = .high + context.draw(self, in: CGRect(origin: .zero, size: newSize)) + return context.makeImage()! + } +} diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 96d09f92..47a4524f 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -15,7 +15,7 @@ class Application: NSObject { var pid: pid_t var isHidden: Bool var hasBeenActiveOnce: Bool - var icon: NSImage? + var icon: CGImage? var dockLabel: String? var focusedWindow: Window? = nil var alreadyRequestedToQuit = false @@ -25,7 +25,7 @@ class Application: NSObject { pid = runningApplication.processIdentifier isHidden = runningApplication.isHidden hasBeenActiveOnce = runningApplication.isActive - icon = runningApplication.icon + icon = runningApplication.icon?.cgImage(maxSize: NSSize(width: 1024, height: 1024)) localizedName = runningApplication.localizedName bundleIdentifier = runningApplication.bundleIdentifier bundleURL = runningApplication.bundleURL diff --git a/src/logic/Window.swift b/src/logic/Window.swift index 3ea23104..e66cad77 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -6,9 +6,8 @@ class Window { var lastFocusOrder = Int.zero var creationOrder = Int.zero var title: String! - var thumbnail: NSImage? - var thumbnailFullSize: NSSize? - var icon: NSImage? { get { application.icon } } + var thumbnail: CGImage? + var icon: CGImage? { get { application.icon } } var shouldShowTheUser = true var isTabbed: Bool = false var isHidden: Bool { get { application.isHidden } } @@ -96,17 +95,16 @@ class Window { CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes) } - func refreshThumbnail(_ screenshot: NSImage) { + func refreshThumbnail(_ screenshot: CGImage) { thumbnail = screenshot - thumbnailFullSize = screenshot.size if !App.app.appIsBeingUsed || !shouldShowTheUser { return } - if let view = (ThumbnailsView.recycledViews.first { $0.window_?.cgWindowId == cgWindowId }) { + if let size, + let view = (ThumbnailsView.recycledViews.first { $0.window_?.cgWindowId == cgWindowId }) { if !view.thumbnail.isHidden { - view.thumbnail.image = thumbnail?.copyToSeparateContexts() - let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail, false) - view.thumbnail.setSize(thumbnailSize) + let thumbnailSize = ThumbnailView.thumbnailSize(screenshot, false) + view.thumbnail.updateWithResizedCopy(screenshot, thumbnailSize) } - App.app.previewPanel.updateImageIfShowing(cgWindowId, screenshot, screenshot.size) + App.app.previewPanel.updateImageIfShowing(cgWindowId, screenshot, size) } } diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index 7d5a5825..a109b281 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -55,7 +55,7 @@ class Windows { order = .orderedSame } else if $0.isOnAllSpaces { order = .orderedAscending - } else if $1.isOnAllSpaces { + } else if $1.isOnAllSpaces { order = .orderedDescending } else if let spaceIndex0 = $0.spaceIndexes.first, let spaceIndex1 = $1.spaceIndexes.first { order = spaceIndex0.compare(spaceIndex1) @@ -323,7 +323,7 @@ class Windows { if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return } DispatchQueue.main.async { [weak window] in if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return } - window?.refreshThumbnail(NSImage.fromCgImage(cgImage)) + window?.refreshThumbnail(cgImage) } } } diff --git a/src/ui/App.swift b/src/ui/App.swift index 47e12d21..7d815c85 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -15,6 +15,7 @@ class App: AppCenterApplication { static let licence = Bundle.main.object(forInfoDictionaryKey: "NSHumanReadableCopyright") as! String static let repository = "https://github.com/lwouis/alt-tab-macos" static let website = "https://alt-tab-macos.netlify.app" + static let appIcon = CGImage.named("app.icns") static var app: App! var thumbnailsPanel: ThumbnailsPanel! var previewPanel: PreviewPanel! diff --git a/src/ui/FeedbackWindow.swift b/src/ui/FeedbackWindow.swift index 914b2c1a..163b4f9d 100644 --- a/src/ui/FeedbackWindow.swift +++ b/src/ui/FeedbackWindow.swift @@ -28,14 +28,16 @@ class FeedbackWindow: NSWindow { } private func setupView() { - let appIcon = NSImageView(image: NSImage.initResizedCopy("app", 80, 80)) - appIcon.imageScaling = .scaleNone + let appIcon = LightImageView() + appIcon.updateWithResizedCopy(App.appIcon, NSSize(width: 80, height: 80), fixBitmapInfo: true) + appIcon.fit(80, 80) let appText = StackView([ BoldLabel(NSLocalizedString("Share improvement ideas, or report bugs", comment: "")), HyperlinkLabel(NSLocalizedString("View existing discussions", comment: ""), App.repository + "/issues"), ], .vertical) appText.spacing = GridView.interPadding / 2 let header = NSStackView(views: [appIcon, appText]) + header.translatesAutoresizingMaskIntoConstraints = false header.spacing = GridView.interPadding sendButton = NSButton(title: NSLocalizedString("Send", comment: ""), target: nil, action: #selector(sendCallback)) sendButton.keyEquivalent = "\r" diff --git a/src/ui/generic-components/LightImageView.swift b/src/ui/generic-components/LightImageView.swift new file mode 100644 index 00000000..2db28638 --- /dev/null +++ b/src/ui/generic-components/LightImageView.swift @@ -0,0 +1,39 @@ +import Cocoa + +/// this is a lightweight view which displays an image using its CALayer +/// it is an alternative to NSImageView, which doesn't have internal complexities and performance costs +class LightImageView: NSView { + var image: CGImage? { get { layer!.contents as! CGImage? } } + + required init?(coder: NSCoder) { + fatalError("Class only supports programmatic initialization") + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + translatesAutoresizingMaskIntoConstraints = false + wantsLayer = true + layer!.contentsGravity = .resize + layer!.magnificationFilter = .trilinear + layer!.minificationFilter = .trilinear + layer!.minificationFilterBias = 0.0 + layer!.shouldRasterize = false + } + + func updateWithResizedCopy(_ image: CGImage?, _ size: NSSize, fixBitmapInfo: Bool = false) { + let scaleFactor = NSScreen.preferred.backingScaleFactor + if let image { + CATransaction.begin() + // disable implicit fade-in animation from CALayer + CATransaction.setDisableActions(true) + // alternatively, we could set layer!.contentsGravity to .center, and use the lines bellow to resize ourselves + // let scaledSize = NSSize(width: size.width * scaleFactor, height: size.height * scaleFactor) + // layer!.contents = image.resizedCopyWithCoreGraphics(scaledSize, fixBitmapInfo) + // it would produce subjectively better quality, but the resizing would be done on the CPU so poor performance + layer!.contents = image + layer!.contentsScale = scaleFactor + CATransaction.commit() + } + frame.size = size + } +} diff --git a/src/ui/generic-components/TrafficLightButton.swift b/src/ui/generic-components/TrafficLightButton.swift index 47cf0efe..055cd015 100644 --- a/src/ui/generic-components/TrafficLightButton.swift +++ b/src/ui/generic-components/TrafficLightButton.swift @@ -9,14 +9,14 @@ class TrafficLightButton: NSButton { var window_: Window? init(_ type: TrafficLightButtonType, _ tooltip: String) { - super.init(frame: .init(origin: .zero, size: .init(width: TrafficLightButton.size, height: TrafficLightButton.size))) + super.init(frame: NSRect(origin: .zero, size: NSSize(width: TrafficLightButton.size, height: TrafficLightButton.size))) self.type = type target = self action = #selector(onClick) fit(TrafficLightButton.size, TrafficLightButton.size) addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeInKeyWindow], owner: self, userInfo: nil)) toolTip = tooltip - appearance = .init(named: .aqua) + appearance = NSAppearance(named: .aqua) } required init?(coder: NSCoder) { diff --git a/src/ui/main-window/PreviewPanel.swift b/src/ui/main-window/PreviewPanel.swift index 3bf31262..a748a5a6 100644 --- a/src/ui/main-window/PreviewPanel.swift +++ b/src/ui/main-window/PreviewPanel.swift @@ -1,7 +1,7 @@ import Cocoa class PreviewPanel: NSPanel { - private let previewView = NSImageView() + private let previewView = LightImageView() private let borderView = BorderView() private var currentId: CGWindowID? @@ -27,17 +27,15 @@ class PreviewPanel: NSPanel { setAccessibilitySubrole(.unknown) } - func updateImageIfShowing(_ id: CGWindowID?, _ preview: NSImage, _ size: CGSize) { - if id == currentId && previewView.image != preview { - previewView.image = preview.copyToSeparateContexts() - previewView.image!.size = size + func updateImageIfShowing(_ id: CGWindowID?, _ preview: CGImage, _ size: CGSize) { + if id == currentId { + previewView.updateWithResizedCopy(preview, size) } } - func show(_ id: CGWindowID, _ preview: NSImage, _ position: CGPoint, _ size: CGSize) { - if previewView.image != preview { - previewView.image = preview.copyToSeparateContexts() - previewView.image!.size = size + func show(_ id: CGWindowID, _ preview: CGImage, _ position: CGPoint, _ size: CGSize) { + if id != currentId { + previewView.updateWithResizedCopy(preview, size) var frame = NSRect(origin: position, size: size) // Flip Y coordinate from Quartz (0,0 at bottom-left) to Cocoa coordinates (0,0 at top-left) // Always use the primary screen as reference since all coordinates are relative to it diff --git a/src/ui/main-window/ThumbnailView.swift b/src/ui/main-window/ThumbnailView.swift index 50b4949b..53c38101 100644 --- a/src/ui/main-window/ThumbnailView.swift +++ b/src/ui/main-window/ThumbnailView.swift @@ -3,10 +3,10 @@ import Cocoa class ThumbnailView: FlippedView { static let noOpenWindowToolTip = NSLocalizedString("App is running but has no open window", comment: "") var window_: Window? - var thumbnail = NSImageView() - var windowlessIcon = NSImageView() + var thumbnail = LightImageView() + var windowlessIcon = LightImageView() let thumbnailContainer = FlippedView() - var appIcon = NSImageView() + var appIcon = LightImageView() var label = ThumbnailTitleView(shadow: ThumbnailView.makeShadow(Appearance.titleShadowColor), font: Appearance.font) var fullscreenIcon = ThumbnailFontIconView(symbol: .circledPlusSign, tooltip: NSLocalizedString("Window is fullscreen", comment: "")) var minimizedIcon = ThumbnailFontIconView(symbol: .circledMinusSign, tooltip: NSLocalizedString("Window is minimized", comment: "")) @@ -137,8 +137,7 @@ class ThumbnailView: FlippedView { } } - @discardableResult - func updateDockLabelIcon(_ dockLabel: String?) -> Bool { + func updateDockLabelIcon(_ dockLabel: String?) { assignIfDifferent(&dockLabelIcon.isHidden, dockLabel == nil || Preferences.hideAppBadges || Appearance.iconSize == 0) if !dockLabelIcon.isHidden, let dockLabel { let view = dockLabelIcon.subviews[1] as! ThumbnailFontIconView @@ -149,9 +148,7 @@ class ThumbnailView: FlippedView { view.setNumber(dockLabelInt!, true) } view.setAccessibilityLabel(getAccessibilityTextForBadge(dockLabel)) - return true } - return false } private func setupView() { @@ -170,12 +167,9 @@ class ThumbnailView: FlippedView { thumbnail.shadow = shadow windowlessIcon.shadow = shadow appIcon.shadow = shadow - thumbnail.imageScaling = .scaleProportionallyUpOrDown windowlessIcon.translatesAutoresizingMaskIntoConstraints = false - windowlessIcon.imageScaling = .scaleProportionallyUpOrDown windowlessIcon.toolTip = ThumbnailView.noOpenWindowToolTip appIcon.translatesAutoresizingMaskIntoConstraints = false - appIcon.imageScaling = .scaleProportionallyUpOrDown appIcon.setSubviewAbove(dockLabelIcon) label.fixHeight() vStackView.wantsLayer = true @@ -306,28 +300,10 @@ class ThumbnailView: FlippedView { view.label.toolTip = view.label.cell!.cellSize.width >= view.label.frame.size.width ? view.label.stringValue : nil } - private func updateThumbnail(_ element: Window) { - if !thumbnail.isHidden && thumbnail != element.thumbnail { - if let screenshot = element.thumbnail { - thumbnail.image = screenshot.copyToSeparateContexts() - } else { - thumbnail.image = element.icon?.copyToSeparateContexts() - // if no thumbnail, show appIcon instead - // .icon is always 32x32 by default. We scale it so it can be "thumbnail-ed" down - thumbnail.setSize(NSSize(width: 1024, height: 1024)) - } - // for Accessibility > "speak items under the pointer" - thumbnail.setAccessibilityLabel(element.title) - } - } - - private func updateAppIcon(_ element: Window, _ title: String) -> Bool { - let appIconChanged = appIcon.image != element.icon - if appIconChanged { - appIcon.image = element.icon?.copyToSeparateContexts() - appIcon.setAccessibilityLabel(title) - } - return appIconChanged + private func updateAppIcon(_ element: Window, _ title: String) { + let appIconSize = ThumbnailView.iconSize() + appIcon.updateWithResizedCopy(element.icon, appIconSize) + appIcon.setAccessibilityLabel(title) } private func updateValues(_ element: Window, _ index: Int, _ newHeight: CGFloat) { @@ -342,7 +318,18 @@ class ThumbnailView: FlippedView { NSScreen.screens.count < 2 || Preferences.screensToShow[App.app.shortcutIndex] == .showingAltTab ) )) - updateThumbnail(element) + if !thumbnail.isHidden { + if let screenshot = element.thumbnail { + let thumbnailSize = ThumbnailView.thumbnailSize(screenshot, false) + thumbnail.updateWithResizedCopy(screenshot, thumbnailSize) + } else { + // if no thumbnail, show appIcon instead + let thumbnailSize = ThumbnailView.thumbnailSize(element.icon, true) + thumbnail.updateWithResizedCopy(element.icon, thumbnailSize) + } + // for Accessibility > "speak items under the pointer" + thumbnail.setAccessibilityLabel(element.title) + } let title = getAppOrAndWindowTitle() let labelChanged = label.stringValue != title if labelChanged { @@ -360,13 +347,12 @@ class ThumbnailView: FlippedView { spaceIcon.toolTip = String(format: NSLocalizedString("Window is on Space %d", comment: ""), spaceIndex) } } - let appIconChanged = updateAppIcon(element, title) - let dockLabelChanged = updateDockLabelIcon(element.dockLabel) - if appIconChanged || dockLabelChanged { - setAccessibilityHelp(getAccessibilityHelp(element.application.localizedName, element.dockLabel)) - } - if element.isWindowlessApp && windowlessIcon.image != element.icon { - windowlessIcon.image = element.icon?.copyToSeparateContexts() + updateAppIcon(element, title) + updateDockLabelIcon(element.dockLabel) + setAccessibilityHelp(getAccessibilityHelp(element.application.localizedName, element.dockLabel)) + if !windowlessIcon.isHidden { + let windowlessIconSize = ThumbnailView.thumbnailSize(element.icon, true) + windowlessIcon.updateWithResizedCopy(element.icon, windowlessIconSize) } windowControlIcons.forEach { $0.window_ = element } showOrHideWindowControls(isShowingWindowControls) @@ -375,19 +361,7 @@ class ThumbnailView: FlippedView { } private func updateSizes(_ newHeight: CGFloat) { - if Preferences.appearanceStyle == .thumbnails { - if !thumbnail.isHidden { - let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail.image, false) - thumbnail.setSize(thumbnailSize) - } - if !windowlessIcon.isHidden { - let windowlessIconSize = ThumbnailView.thumbnailSize(windowlessIcon.image, true) - windowlessIcon.setSize(windowlessIconSize) - } - } setFrameWidthHeight(newHeight) - let appIconSize = ThumbnailView.iconSize() - appIcon.setSize(appIconSize) if Preferences.appearanceStyle == .appIcons { vStackView.frame.size = NSSize(width: frame.width, height: appIcon.frame.height + Appearance.edgeInsetsSize * 2) hStackView.frame.size = NSSize(width: appIcon.frame.width, height: appIcon.frame.height) @@ -545,8 +519,10 @@ class ThumbnailView: FlippedView { return ((ThumbnailsPanel.maxThumbnailsHeight() - Appearance.interCellPadding) / Appearance.rowsCount - Appearance.interCellPadding).rounded() } - static func thumbnailSize(_ image: NSImage?, _ isWindowlessApp: Bool) -> NSSize { + static func thumbnailSize(_ image: CGImage?, _ isWindowlessApp: Bool) -> NSSize { guard let image else { return NSSize(width: 0, height: 0) } + let imageWidth = CGFloat(image.width) + let imageHeight = CGFloat(image.height) let thumbnailHeightMax = ThumbnailView.maxThumbnailHeight() - Appearance.edgeInsetsSize * 2 - Appearance.intraCellPadding @@ -554,26 +530,26 @@ class ThumbnailView: FlippedView { let thumbnailWidthMax = ThumbnailView.maxThumbnailWidth() - Appearance.edgeInsetsSize * 2 // don't stretch very small windows; keep them 1:1 in the switcher - if !isWindowlessApp && image.size.width < thumbnailWidthMax && image.size.height < thumbnailHeightMax { - return image.size + if !isWindowlessApp && imageWidth < thumbnailWidthMax && imageHeight < thumbnailHeightMax { + return NSSize(width: imageWidth, height: imageHeight) } - let thumbnailHeight = min(image.size.height, thumbnailHeightMax) - let thumbnailWidth = min(image.size.width, thumbnailWidthMax) - let imageRatio = image.size.width / image.size.height + let thumbnailHeight = min(imageHeight, thumbnailHeightMax) + let thumbnailWidth = min(imageWidth, thumbnailWidthMax) + let imageRatio = imageWidth / imageHeight let thumbnailRatio = thumbnailWidth / thumbnailHeight var width: CGFloat var height: CGFloat if thumbnailRatio > imageRatio { // Keep the height and reduce the width - width = image.size.width * thumbnailHeight / image.size.height + width = imageWidth * thumbnailHeight / imageHeight height = thumbnailHeight } else if thumbnailRatio < imageRatio { // Keep the width and reduce the height width = thumbnailWidth - height = image.size.height * thumbnailWidth / image.size.width + height = imageHeight * thumbnailWidth / imageWidth } else { // Enlarge the height to the maximum height and enlarge the width - width = thumbnailHeightMax / image.size.height * image.size.width + width = thumbnailHeightMax / imageHeight * imageWidth height = thumbnailHeightMax } return NSSize(width: width.rounded(), height: height.rounded()) diff --git a/src/ui/permission-window/PermissionsWindow.swift b/src/ui/permission-window/PermissionsWindow.swift index bdf0c664..477dcc5a 100644 --- a/src/ui/permission-window/PermissionsWindow.swift +++ b/src/ui/permission-window/PermissionsWindow.swift @@ -34,12 +34,14 @@ class PermissionsWindow: NSWindow { } private func setupView() { - let appIcon = NSImageView(image: NSImage.initResizedCopy("app", 80, 80)) - appIcon.imageScaling = .scaleNone + let appIcon = LightImageView() + appIcon.updateWithResizedCopy(App.appIcon, NSSize(width: 80, height: 80), fixBitmapInfo: true) + appIcon.fit(80, 80) let appText = TitleLabel(NSLocalizedString("AltTab needs some permissions", comment: "")) appText.preferredMaxLayoutWidth = 380 appText.font = .systemFont(ofSize: 25, weight: .regular) let header = NSStackView(views: [appIcon, appText]) + header.translatesAutoresizingMaskIntoConstraints = false header.spacing = GridView.interPadding accessibilityView = PermissionView( "accessibility", diff --git a/src/ui/preferences-window/PreferencesWindow.swift b/src/ui/preferences-window/PreferencesWindow.swift index 5c4221a2..519afc38 100644 --- a/src/ui/preferences-window/PreferencesWindow.swift +++ b/src/ui/preferences-window/PreferencesWindow.swift @@ -71,9 +71,10 @@ class PreferencesWindow: NSWindow { } } // it seems that new instances of NSToolbarItems have their image set to a 1x1 image - if item.image == nil || item.image!.size == .init(width: 1, height: 1) { - item.image = NSImage.initTemplateCopy(id) - item.maxSize = .init(width: 22, height: 22) + if item.image == nil || item.image!.size == NSSize(width: 1, height: 1) { + item.image = NSImage.initCopy(id) + item.image!.isTemplate = true + item.maxSize = NSSize(width: 22, height: 22) } item.target = self item.action = #selector(tabItemClicked) diff --git a/src/ui/preferences-window/tabs/AboutTab.swift b/src/ui/preferences-window/tabs/AboutTab.swift index 02f0c137..c43e93d1 100644 --- a/src/ui/preferences-window/tabs/AboutTab.swift +++ b/src/ui/preferences-window/tabs/AboutTab.swift @@ -2,8 +2,9 @@ import Cocoa class AboutTab { static func initTab() -> NSView { - let appIcon = NSImageView(image: NSImage.initResizedCopy("app", 256, 256)) - appIcon.imageScaling = .scaleNone + let appIcon = LightImageView() + appIcon.updateWithResizedCopy(App.appIcon, NSSize(width: 256, height: 256), fixBitmapInfo: true) + appIcon.fit(256, 256) let appText = StackView([ BoldLabel(App.name), NSTextField(wrappingLabelWithString: NSLocalizedString("Version", comment: "") + " " + App.version), @@ -14,7 +15,8 @@ class AboutTab { appText.spacing = GridView.interPadding / 2 let rowToSeparate = 3 appText.views[rowToSeparate].topAnchor.constraint(equalTo: appText.views[rowToSeparate - 1].bottomAnchor, constant: GridView.interPadding).isActive = true - let appInfo = StackView([appIcon, appText]) + let appInfo = NSStackView(views: [appIcon, appText]) + appIcon.translatesAutoresizingMaskIntoConstraints = false appInfo.spacing = GridView.interPadding appInfo.alignment = .centerY let sendFeedback = NSButton(title: NSLocalizedString("Send feedback…", comment: ""), target: nil, action: #selector(App.app.showFeedbackPanel)) diff --git a/src/ui/preferences-window/tabs/BlacklistsTab.swift b/src/ui/preferences-window/tabs/BlacklistsTab.swift index 8085c3fd..f481c4d7 100644 --- a/src/ui/preferences-window/tabs/BlacklistsTab.swift +++ b/src/ui/preferences-window/tabs/BlacklistsTab.swift @@ -4,7 +4,7 @@ import Sparkle class BlacklistsTab { static func initTab() -> NSView { let blacklist = BlacklistView() - let add = NSSegmentedControl.init(images: [NSImage(named: NSImage.addTemplateName)!, NSImage(named: NSImage.removeTemplateName)!], trackingMode: .momentary, target: nil, action: nil) + let add = NSSegmentedControl(images: [NSImage(named: NSImage.addTemplateName)!, NSImage(named: NSImage.removeTemplateName)!], trackingMode: .momentary, target: nil, action: nil) add.onAction = { let tableView = blacklist.documentView as! TableView if ($0 as! NSSegmentedControl).selectedSegment == 0 { diff --git a/unit-tests/ImageScalingTests.swift b/unit-tests/ImageScalingTests.swift new file mode 100644 index 00000000..f9dcaeac --- /dev/null +++ b/unit-tests/ImageScalingTests.swift @@ -0,0 +1,90 @@ +//import XCTest +// +//final class ImageScalingTests: XCTestCase { +// /// 4ms +// func testCgImageResizedWithCoreGraphics() throws { +// let image = load20MbImage().cgImage() +// measure(options: options) { +// let _ = image.resizedCopyWithCoreGraphics(NSSize(width: 200, height: 200)) +// } +// } +// +// /// 4ms +// func testNsImageResizedWithCoreGraphics() throws { +// let image = load20MbImage() +// measure(options: options) { +// let _ = image.resizedWithCoreGraphics(NSSize(width: 200, height: 200)) +// } +// } +// +// /// 9ms +// func testResizedWithNSGraphicsContext() throws { +// let image = load20MbImage() +// measure(options: options) { +// let _ = image.resizedWithNSGraphicsContext(NSSize(width: 200, height: 200)) +// } +// } +// +// /// 42s +// func testResizedWithCoreImage() throws { +// let image = load20MbImage() +// measure(options: options) { +// let _ = image.resizedWithCoreImage(NSSize(width: 200, height: 200)) +// } +// } +// +// private var options: XCTMeasureOptions = { +// let o = XCTMeasureOptions() +// o.iterationCount = 10 +// return o +// }() +// +// private func load20MbImage() -> NSImage { +// let bundle = Bundle(for: type(of: self)) +// let imagePath = bundle.path(forResource: "20MB-photo.jpg", ofType: nil)! +// return NSImage(contentsOfFile: imagePath)! +// } +//} +// +//extension NSImage { +// func resizedWithNSGraphicsContext(_ newSize: NSSize) -> NSImage { +// let newImage = NSImage(size: newSize) +// newImage.lockFocus() +// NSGraphicsContext.current?.imageInterpolation = .high +// self.draw(in: NSRect(origin: .zero, size: newSize), from: NSRect(origin: .zero, size: size), +// operation: .copy, fraction: 1.0) +// newImage.unlockFocus() +// return newImage +// } +// +// @available(macOS 10.15, *) +// func resizedWithCoreImage(_ newSize: NSSize) -> NSImage { +// let ciImage = CIImage(data: tiffRepresentation!)! +// let filter = CIFilter.lanczosScaleTransform() +// filter.inputImage = ciImage +// let scaleX = newSize.width / size.width +// let scaleY = newSize.height / size.height +// let scale = min(scaleX, scaleY) // Preserve aspect ratio +// filter.scale = Float(scale) +// filter.aspectRatio = 1.0 +// let outputCIImage = filter.outputImage! +// let context = CIContext(options: [CIContextOption.useSoftwareRenderer: false]) // Prefer GPU +// let outputCGImage = context.createCGImage(outputCIImage, from: outputCIImage.extent)! +// return NSImage(cgImage: outputCGImage, size: newSize) +// } +// +// func resizedWithCoreGraphics(_ newSize: NSSize) -> NSImage { +// let image = cgImage() +// let context = CGContext(data: nil, +// width: Int(newSize.width), +// height: Int(newSize.height), +// bitsPerComponent: image.bitsPerComponent, +// bytesPerRow: 0, +// space: image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!, +// bitmapInfo: image.bitmapInfo.rawValue)! +// context.interpolationQuality = .high +// context.draw(image, in: CGRect(origin: .zero, size: newSize)) +// let scaledImage = context.makeImage()! +// return NSImage(cgImage: scaledImage, size: newSize) +// } +//} diff --git a/unit-tests/resources/20MB-photo.jpg b/unit-tests/resources/20MB-photo.jpg new file mode 100644 index 00000000..13628e04 Binary files /dev/null and b/unit-tests/resources/20MB-photo.jpg differ