diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index c14b0010..295daed9 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -194,11 +194,6 @@ extension DispatchQoS { } extension NSImage { - func appIconFixedSize(_ size: NSSize) -> CGImage? { - var rect = NSRect(origin: .zero, size: size) - 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 diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 50771391..d04a61ca 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -45,16 +45,19 @@ class Application: NSObject { return 0 }() - private static func appIconWithoutPadding(_ icon: NSImage?) -> CGImage? { + static func appIconWithoutPadding(_ icon: NSImage?) -> CGImage? { guard let icon else { return nil } - // we can render the icon quite big (e.g. windowless app icon), so we store it high-res - let iconWidth = CGFloat(1024) - // NSRunningApplication.icon returns icons with padding; we remove it manually - - let croppedSize = iconWidth - appIconPadding * 2 - return icon - .appIconFixedSize(NSSize(width: iconWidth, height: iconWidth))? - .cropping(to: CGRect(x: appIconPadding, y: appIconPadding, width: croppedSize, height: croppedSize).integral) + let finalWidth = max(ThumbnailsPanel.maxPossibleAppIconSize.width, ThumbnailsPanel.maxPossibleAppIconSize.height) + let padding = appIconPadding * (finalWidth / (1024 - appIconPadding * 2)) + let sourceWidth = finalWidth + padding * 2 + guard let context = CGContext(data: nil, width: Int(finalWidth), height: Int(finalWidth), bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } + context.interpolationQuality = .high + let drawRect = CGRect(x: -padding, y: -padding, width: sourceWidth, height: sourceWidth) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: false) + icon.draw(in: drawRect, from: .zero, operation: .copy, fraction: 1.0, respectFlipped: false, hints: nil) + NSGraphicsContext.restoreGraphicsState() + return context.makeImage() } init(_ runningApplication: NSRunningApplication) { diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index 0767064c..1e535204 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -161,4 +161,16 @@ class Applications { static func find(_ pid: pid_t?) -> Application? { return list.first { $0.pid == pid } } + + static func updateAppIcons() { + for app in list { + BackgroundWork.screenshotsQueue.addOperation { [weak app] in + guard let app else { return } + let r = Application.appIconWithoutPadding(app.runningApplication.icon) + DispatchQueue.main.async { [weak app] in + app?.icon = r + } + } + } + } } diff --git a/src/logic/Window.swift b/src/logic/Window.swift index fc30f761..e248c4c1 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -102,7 +102,7 @@ class Window { if !view.thumbnail.isHidden { let thumbnailSize = ThumbnailView.thumbnailSize(screenshot.size(), false) let newSize = thumbnailSize.width != view.thumbnail.frame.width || thumbnailSize.height != view.thumbnail.frame.height - view.thumbnail.updateWithResizedCopy(screenshot, thumbnailSize) + view.thumbnail.updateContents(screenshot, thumbnailSize) // if the thumbnail size has changed, we need to refresh the open UI if newSize { App.app.refreshOpenUi([], .refreshOnlyThumbnailsAfterShowUi) diff --git a/src/logic/events/SpacesEvents.swift b/src/logic/events/SpacesEvents.swift index 79082a3d..c4ed447a 100644 --- a/src/logic/events/SpacesEvents.swift +++ b/src/logic/events/SpacesEvents.swift @@ -8,7 +8,6 @@ class SpacesEvents { @objc private static func handleEvent(_ notification: Notification) { ScreensEvents.debouncerScreenAndSpace.debounce(.spaceEvent) { Logger.debug { notification.name.rawValue } - ThumbnailsPanel.updateMaxPossibleThumbnailSize() // Workaround for Safari full-screen videos // when full-screening a video, Safari spawns a second full-screen window called "Safari" // this window doesn't emit resize/move events. It doesn't pass isActualWindow on creation. It's added on focusedWindowChanged diff --git a/src/ui/App.swift b/src/ui/App.swift index af403048..4a084913 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -306,6 +306,7 @@ extension App: NSApplicationDelegate { SpacesEvents.observe() ScreensEvents.observe() ThumbnailsPanel.updateMaxPossibleThumbnailSize() + ThumbnailsPanel.updateMaxPossibleAppIconSize() SystemAppearanceEvents.observe() SystemScrollerStyleEvents.observe() Applications.initialDiscovery() diff --git a/src/ui/FeedbackWindow.swift b/src/ui/FeedbackWindow.swift index 7cca89e7..c4175293 100644 --- a/src/ui/FeedbackWindow.swift +++ b/src/ui/FeedbackWindow.swift @@ -29,7 +29,7 @@ class FeedbackWindow: NSWindow { private func setupView() { let appIcon = LightImageView() - appIcon.updateWithResizedCopy(.cgImage(App.appIcon), NSSize(width: 80, height: 80)) + appIcon.updateContents(.cgImage(App.appIcon), NSSize(width: 80, height: 80)) appIcon.fit(80, 80) let appText = StackView([ BoldLabel(NSLocalizedString("Share improvement ideas, or report bugs", comment: "")), diff --git a/src/ui/generic-components/LightImageView.swift b/src/ui/generic-components/LightImageView.swift index 8e329b0d..7036fbd3 100644 --- a/src/ui/generic-components/LightImageView.swift +++ b/src/ui/generic-components/LightImageView.swift @@ -27,12 +27,7 @@ class LightImageView: NSView { layerContentsRedrawPolicy = .never } - func updateWithResizedCopy(_ caLayerContents: CALayerContents, _ size: NSSize) { - let scaleFactor = NSScreen.preferred.backingScaleFactor - // 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 + func updateContents(_ caLayerContents: CALayerContents, _ size: NSSize) { switch caLayerContents { case .cgImage(let image?): layer!.contents = image @@ -40,7 +35,6 @@ class LightImageView: NSView { layer!.contents = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() default: break } - layer!.contentsScale = scaleFactor if frame.size != size { frame.size = size } diff --git a/src/ui/main-window/PreviewPanel.swift b/src/ui/main-window/PreviewPanel.swift index adcf8bb8..23272789 100644 --- a/src/ui/main-window/PreviewPanel.swift +++ b/src/ui/main-window/PreviewPanel.swift @@ -30,7 +30,7 @@ class PreviewPanel: NSPanel { func show(_ id: CGWindowID, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) { if id != currentId { repositionAndResize(position, size) - previewView.updateWithResizedCopy(preview, size) + previewView.updateContents(preview, size) } if id != currentId || !isVisible { if Preferences.previewFadeInAnimation { @@ -54,7 +54,7 @@ class PreviewPanel: NSPanel { func updateIfShowing(_ id: CGWindowID?, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) { if isVisible && id == currentId { repositionAndResize(position, size) - previewView.updateWithResizedCopy(preview, size) + previewView.updateContents(preview, size) } } diff --git a/src/ui/main-window/ThumbnailView.swift b/src/ui/main-window/ThumbnailView.swift index f0db1905..a3664c90 100644 --- a/src/ui/main-window/ThumbnailView.swift +++ b/src/ui/main-window/ThumbnailView.swift @@ -323,7 +323,7 @@ class ThumbnailView: FlippedView { private func updateAppIcon(_ element: Window, _ title: String) { let appIconSize = ThumbnailView.iconSize() - appIcon.updateWithResizedCopy(.cgImage(element.icon), appIconSize) + appIcon.updateContents(.cgImage(element.icon), appIconSize) appIcon.setAccessibilityLabel(title) } @@ -342,11 +342,11 @@ class ThumbnailView: FlippedView { if !thumbnail.isHidden { if let screenshot = element.thumbnail { let thumbnailSize = ThumbnailView.thumbnailSize(element.size, false) - thumbnail.updateWithResizedCopy(screenshot, thumbnailSize) + thumbnail.updateContents(screenshot, thumbnailSize) } else { // if no thumbnail, show appIcon instead let thumbnailSize = ThumbnailView.thumbnailSize(element.icon?.size(), true) - thumbnail.updateWithResizedCopy(.cgImage(element.icon), thumbnailSize) + thumbnail.updateContents(.cgImage(element.icon), thumbnailSize) } // for Accessibility > "speak items under the pointer" thumbnail.setAccessibilityLabel(element.title) @@ -373,7 +373,7 @@ class ThumbnailView: FlippedView { setAccessibilityHelp(getAccessibilityHelp(element.application.localizedName, element.dockLabel)) if !windowlessIcon.isHidden { let windowlessIconSize = ThumbnailView.thumbnailSize(element.icon?.size(), true) - windowlessIcon.updateWithResizedCopy(.cgImage(element.icon), windowlessIconSize) + windowlessIcon.updateContents(.cgImage(element.icon), windowlessIconSize) } windowControlIcons.forEach { $0.window_ = element } showOrHideWindowControls(isShowingWindowControls) @@ -575,8 +575,8 @@ class ThumbnailView: FlippedView { return maxWidth } - static func minThumbnailWidth() -> CGFloat { - return ThumbnailsPanel.maxThumbnailsWidth() * Appearance.windowMinWidthInRow - Appearance.interCellPadding * 2 + static func minThumbnailWidth(_ screen: NSScreen = NSScreen.preferred) -> CGFloat { + return ThumbnailsPanel.maxThumbnailsWidth(screen) * Appearance.windowMinWidthInRow - Appearance.interCellPadding * 2 } /// The maximum height that a thumbnail can be drawn @@ -622,9 +622,9 @@ class ThumbnailView: FlippedView { return NSSize(width: width.rounded(), height: height.rounded()) } - static func iconSize() -> NSSize { + static func iconSize(_ screen: NSScreen = NSScreen.preferred) -> NSSize { if Preferences.appearanceStyle == .appIcons { - let widthMin = ThumbnailView.minThumbnailWidth() + let widthMin = ThumbnailView.minThumbnailWidth(screen) let contentWidth = Appearance.iconSize let frameWidth = contentWidth + Appearance.edgeInsetsSize * 2 let width = max(frameWidth, widthMin).rounded() diff --git a/src/ui/main-window/ThumbnailsPanel.swift b/src/ui/main-window/ThumbnailsPanel.swift index bd4cefdd..db9ff034 100644 --- a/src/ui/main-window/ThumbnailsPanel.swift +++ b/src/ui/main-window/ThumbnailsPanel.swift @@ -5,6 +5,7 @@ class ThumbnailsPanel: NSPanel { override var canBecomeKey: Bool { true } private var didDisplayOnce = false static var maxPossibleThumbnailSize = NSSize.zero + static var maxPossibleAppIconSize = NSSize.zero convenience init() { self.init(contentRect: .zero, styleMask: .nonactivatingPanel, backing: .buffered, defer: false) @@ -96,7 +97,22 @@ class ThumbnailsPanel: NSPanel { (max(acc.0, ThumbnailView.maxThumbnailWidth(screen) * screen.backingScaleFactor), max(acc.1, ThumbnailView.maxThumbnailHeight(screen) * screen.backingScaleFactor)) } - maxPossibleThumbnailSize = NSSize(width: w, height: h) + maxPossibleThumbnailSize = NSSize(width: w.rounded(), height: h.rounded()) + } + + static func updateMaxPossibleAppIconSize() { + let (w, h) = NSScreen.screens.reduce((CGFloat.zero, CGFloat.zero)) { acc, screen in + // in Thumbnails Appearance, AppIcons can be used for windowless apps, thus much bigger than the app icon near the title + if Preferences.appearanceStyle == .thumbnails { + return (max(acc.0, ThumbnailView.maxThumbnailWidth(screen) * screen.backingScaleFactor), + max(acc.1, ThumbnailView.maxThumbnailHeight(screen) * screen.backingScaleFactor)) + } else { + let size = ThumbnailView.iconSize(screen) + return (max(acc.0, size.width * screen.backingScaleFactor), + max(acc.1, size.height * screen.backingScaleFactor)) + } + } + maxPossibleAppIconSize = NSSize(width: w.rounded(), height: h.rounded()) } } diff --git a/src/ui/main-window/ThumbnailsView.swift b/src/ui/main-window/ThumbnailsView.swift index 89d40664..bc07ef9f 100644 --- a/src/ui/main-window/ThumbnailsView.swift +++ b/src/ui/main-window/ThumbnailsView.swift @@ -24,8 +24,15 @@ class ThumbnailsView { // it would be nicer to remove this whole "reset" logic, and instead update each component to check Appearance properties before showing // Maybe in some Appkit willDraw() function that triggers before drawing it NSScreen.updatePreferred() - ThumbnailsPanel.updateMaxPossibleThumbnailSize() Appearance.update() + // thumbnails are captured continuously. They will pick up the new size on the next cycle + ThumbnailsPanel.updateMaxPossibleThumbnailSize() + // app icons are captured once at launch; we need to manually update them if needed + let old = ThumbnailsPanel.maxPossibleAppIconSize.width + ThumbnailsPanel.updateMaxPossibleAppIconSize() + if old != ThumbnailsPanel.maxPossibleAppIconSize.width { + Applications.updateAppIcons() + } updateBackgroundView() App.app.thumbnailsPanel.contentView = contentView for i in 0.. NSView { let appIcon = LightImageView() - appIcon.updateWithResizedCopy(.cgImage(App.appIcon), NSSize(width: 256, height: 256)) + appIcon.updateContents(.cgImage(App.appIcon), NSSize(width: 256, height: 256)) appIcon.fit(256, 256) let appText = StackView([ BoldLabel(App.name),