feat: improve thumbnails quality and performance (closes #4183)

This commit is contained in:
lwouis committed 2025-02-12 18:01:19 +01:00
1 parent 2cd8b96d38
commit 9d6fc68642
18 files changed
+265 -125

No files matched your search

+26
View File
@@ -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 = "<group>"; };
BF0C80A05C11C8ADC366EC4A /* lb */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = lb; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C80CD087C6384E0340F4E /* convert_font_to_png.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = convert_font_to_png.sh; sourceTree = "<group>"; };
BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HelperExtensionsTestable.swift; sourceTree = "<group>"; };
BF0C8113492C0FFCB4454BC9 /* sl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sl; path = Localizable.strings; sourceTree = "<group>"; };
BF0C8118F91A00A04AD4DE26 /* bg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = bg; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C813DE8B55A735B1720B5 /* subset_font.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = subset_font.sh; sourceTree = "<group>"; };
@@ -388,6 +394,7 @@
BF0C84E11784B3B0900D0963 /* app.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = app.icns; sourceTree = "<group>"; };
BF0C850C46CCD8B978168CCD /* sv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sv; path = Localizable.strings; sourceTree = "<group>"; };
BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AXUIElementTests.swift; sourceTree = "<group>"; };
BF0C854910CCD4FB493C3A5D /* ImageScalingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageScalingTests.swift; sourceTree = "<group>"; };
BF0C85C20516C4210508F349 /* tsconfig.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tsconfig.json; sourceTree = "<group>"; };
BF0C861255F42BF7B23408EF /* sq */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sq; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C863EDD60D925950B223B /* convert_iconset_to_icns.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = convert_iconset_to_icns.sh; sourceTree = "<group>"; };
@@ -473,11 +480,13 @@
BF0C8DF008F1EB5F23291A13 /* SpacesEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpacesEvents.swift; sourceTree = "<group>"; };
BF0C8DF173A246A0E0C30649 /* override_tags_on_poeditor.ts */ = {isa = PBXFileReference; lastKnownFileType = file.ts; path = override_tags_on_poeditor.ts; sourceTree = "<group>"; };
BF0C8DF400A1AF64EB0CD0B3 /* TrafficLightButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrafficLightButton.swift; sourceTree = "<group>"; };
BF0C8E1C49845D813673D7A2 /* 20MB-photo.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "20MB-photo.jpg"; sourceTree = "<group>"; };
BF0C8E1FBCBC7B64C69D933C /* titles-style-full.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "titles-style-full.jpg"; sourceTree = "<group>"; };
BF0C8E25373671B528AD448D /* screen-recording@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "screen-recording@2x.png"; sourceTree = "<group>"; };
BF0C8E478D68ED85DAE6BF43 /* ga */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ga; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8E54658B4DAEA0528200 /* menubar-2@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-2@2x.png"; sourceTree = "<group>"; };
BF0C8E5D2BD5F7F93DF77DF5 /* sk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sk; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8E63BE7394A6591E74E0 /* LightImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightImageView.swift; sourceTree = "<group>"; };
BF0C8E7B087DCC97A454C0AE /* uk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = uk; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8EAF02309B9C489E65F4 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
BF0C8EC400A560FDF99F8C35 /* el */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = el; path = Localizable.strings; sourceTree = "<group>"; };
@@ -975,6 +984,8 @@
BF0C829EAA93A4C4ADB42CF8 /* Mocks.swift */,
BF0C8C6810FF50908E637A48 /* ConcurrentScreenshots.swift */,
BF0C855CD9DBCAEFE48F9EA5 /* AXUIElementTests.swift */,
BF0C854910CCD4FB493C3A5D /* ImageScalingTests.swift */,
BF0C8AE65DDFD6359BE12CB3 /* resources */,
);
path = "unit-tests";
sourceTree = "<group>";
@@ -1041,6 +1052,14 @@
path = ca.lproj;
sourceTree = "<group>";
};
BF0C8AE65DDFD6359BE12CB3 /* resources */ = {
isa = PBXGroup;
children = (
BF0C8E1C49845D813673D7A2 /* 20MB-photo.jpg */,
);
path = resources;
sourceTree = "<group>";
};
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 = "<group>";
@@ -1437,6 +1457,7 @@
BF0C8B841E890CA0D68FFD86 /* Logger.swift */,
BF0C8353097840D7DB1E0E2E /* Markdown.swift */,
BF0C88D101728C6798C7FC07 /* private-apis */,
BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */,
);
path = "api-wrappers";
sourceTree = "<group>";
@@ -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;
};
+13 -28
View File
@@ -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)!
}
}
@@ -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()!
}
}
+2 -2
View File
@@ -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
+8 -10
View File
@@ -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)
}
}
+2 -2
View File
@@ -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)
}
}
}
+1
View File
@@ -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!
+4 -2
View File
@@ -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"
@@ -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
}
}
@@ -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) {
+7 -9
View File
@@ -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
+37 -61
View File
@@ -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())
@@ -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",
@@ -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)
@@ -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))
@@ -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 {
+90
View File
@@ -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)
// }
//}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB