feat: improve thumbnails with screen-capture kit

BREAKING CHANGE: Better thumbnails with ScreenCaptureKit for users on macOS >= 14

* No more WindowServer memory leaks (closes #4255)
* No more purple UI from macOS bothering users
* Fullscreen thumbnails contain top part (closes #3652)
* Thumbnails contain dialogs (e.g. Save/Print panel)
* Show thumbnails for off-screen windows
* Memory usage drastically reduced (e.g. don't save full-resolution if Preview feature is not used)
* Limitations of ScreenCaptureKit compared to private API: can't capture minimized/hidden/tabbed windows
This commit is contained in:
lwouis committed 2026-01-04 22:49:41 +01:00
1 parent e2b47790a9
commit 7821d7c979
20 files changed
+393 -89

No files matched your search

+4
View File
@@ -63,6 +63,7 @@
4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4807A6C523A9CD190052A53E /* SkyLight.framework */; };
48F3E16224EC0D8800A1C64B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BABDA79F8EF76E3ACDD5F /* Localizable.strings */; };
5883F0A829A5182C0071DB65 /* TrackpadEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5883F0A729A5182C0071DB65 /* TrackpadEvents.swift */; };
5F04852E2F09AC9700235D4A /* WindowCaptureEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */; };
5F21C9E02C6E94240091F72F /* ShortcutsWhenActiveSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F21C9DF2C6E94240091F72F /* ShortcutsWhenActiveSheet.swift */; };
5F21C9E22C6E94410091F72F /* AdditionalControlsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F21C9E12C6E94410091F72F /* AdditionalControlsSheet.swift */; };
5F21C9E42C6E94700091F72F /* CustomizeStyleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F21C9E32C6E94700091F72F /* CustomizeStyleSheet.swift */; };
@@ -353,6 +354,7 @@
481FE54624D2D387001032F1 /* alt-tab-macos-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "alt-tab-macos-Bridging-Header.h"; sourceTree = "<group>"; };
5883F0A729A5182C0071DB65 /* TrackpadEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadEvents.swift; sourceTree = "<group>"; };
59DD4BE63D42EEFF1182BA7F /* Pods-alt-tab-macos.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-alt-tab-macos.release.xcconfig"; path = "Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos.release.xcconfig"; sourceTree = "<group>"; };
5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCaptureEvents.swift; sourceTree = "<group>"; };
5F084EAF2EF15E6B00CD212A /* TabbedWindowDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedWindowDetection.swift; sourceTree = "<group>"; };
5F21C9DF2C6E94240091F72F /* ShortcutsWhenActiveSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsWhenActiveSheet.swift; sourceTree = "<group>"; };
5F21C9E12C6E94410091F72F /* AdditionalControlsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionalControlsSheet.swift; sourceTree = "<group>"; };
@@ -1881,6 +1883,7 @@
D04BAC8857A527C2E15D6598 /* events */ = {
isa = PBXGroup;
children = (
5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */,
D04BA8FDCF137D892114F5F3 /* AccessibilityEvents.swift */,
D04BA4A621A8A15660F973C1 /* KeyboardEvents.swift */,
D04BA76AEE37D6656EB80126 /* RunningApplicationsEvents.swift */,
@@ -2446,6 +2449,7 @@
1C96108A0CB2565233FE100B /* TableGroupView.swift in Sources */,
1C9618872DD3863A8EFE8414 /* Appearance.swift in Sources */,
BF0C83578F7292F307E30751 /* PopupButtonLikeSystemSettings.swift in Sources */,
5F04852E2F09AC9700235D4A /* WindowCaptureEvents.swift in Sources */,
1C961F9CF4F1BEDF04ACAEE0 /* Switch.swift in Sources */,
1C96182D2ECA735740ED474E /* ImageTextButtonView.swift in Sources */,
BF0C860F1779DDE8C0171EA0 /* WindowlessAppIndicator.swift in Sources */,
-18
View File
@@ -15,24 +15,6 @@ extension CGWindowID {
return CGSCopySpacesForWindows(CGS_CONNECTION, CGSSpaceMask.all.rawValue, [self] as CFArray) as! [CGSSpaceID]
}
func screenshot() -> CGImage? {
// we use CGSHWCaptureWindowList because it can screenshot minimized windows, which CGWindowListCreateImage can't
var windowId_ = self
let list = CGSHWCaptureWindowList(CGS_CONNECTION, &windowId_, 1, [.ignoreGlobalClipShape, .bestResolution, .fullSize]).takeRetainedValue() as! [CGImage]
return list.first
// // CGWindowListCreateImage
// return CGWindowListCreateImage(.null, .optionIncludingWindow, self, [.boundsIgnoreFraming, .bestResolution])
//
// // CGSCaptureWindowsContentsToRectWithOptions
// var windowId_ = self
// if Testt.sampleCgImage == nil {
// Testt.sampleCgImage = CGWindowListCreateImage(.null, .optionIncludingWindow, self, [.boundsIgnoreFraming, .bestResolution])!
// }
// var image = Testt.sampleCgImage!
// CGSCaptureWindowsContentsToRectWithOptions(cgsMainConnectionId, &windowId_, true, .zero, [.windowCaptureNominalResolution, .captureIgnoreGlobalClipShape], &image)
// return image
}
private func cgProperty<T>(_ key: String, _ type: T.Type) -> T? {
var value: AnyObject?
CGSCopyWindowProperty(CGS_CONNECTION, self, key as CFString, &value)
+9
View File
@@ -221,6 +221,15 @@ extension CGImage {
}
}
extension CVPixelBuffer {
func size() -> NSSize {
NSSize(
width: CVPixelBufferGetWidth(self),
height: CVPixelBufferGetHeight(self)
)
}
}
extension pid_t {
func isZombie() -> Bool {
var kinfo = kinfo_proc()
+14 -1
View File
@@ -51,7 +51,7 @@ class BackgroundWork {
missionControlThread = BackgroundThreadWithRunLoop("missionControl", .userInteractive)
// we listen to CLI commands (CFMessagePort events)
cliEventsThread = BackgroundThreadWithRunLoop("cliMessages", .userInteractive)
// logThreadsAndQueuesOnRepeat()
// logThreadsAndQueuesOnRepeat()
}
static func startCrashReportsQueue() {
@@ -154,6 +154,10 @@ class BackgroundWork {
class LabeledOperationQueue: OperationQueue, @unchecked Sendable {
let strongUnderlyingQueue: DispatchQueue
private var _activeCallbacks: Int32 = 0
var activeCallbacks: Int {
Int(OSAtomicAdd32(0, &_activeCallbacks))
}
init(_ label: String, _ qos: DispatchQoS, _ maxConcurrentOperationCount: Int) {
strongUnderlyingQueue = DispatchQueue(label: label, attributes: [.concurrent])
@@ -173,3 +177,12 @@ class LabeledOperationQueue: OperationQueue, @unchecked Sendable {
}
}
}
extension LabeledOperationQueue {
@inline(__always)
func trackCallbacks<T>(_ body: () throws -> T) rethrows -> T {
OSAtomicIncrement32(&_activeCallbacks)
defer { OSAtomicDecrement32(&_activeCallbacks) }
return try body()
}
}
+6 -1
View File
@@ -134,8 +134,13 @@ class ScreenRecordingPermission {
@available(macOS 12.3, *)
private static func checkWithSCShareableContent() -> Bool {
return runWithTimeout { completion in
SCShareableContent.getWithCompletionHandler { shareableContent, error in
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { shareableContent, error in
// this callback runs on a GCD queue, not on the thread that called getWithCompletionHandler
if #available(macOS 14.0, *), let shareableContent, error == nil {
DispatchQueue.main.async {
WindowCaptureScreenshots.cachedSCWindows = shareableContent.windows
}
}
completion(error != nil ? false : (shareableContent != nil))
}
}
+3 -3
View File
@@ -15,7 +15,7 @@ class Window {
var lastFocusOrder = Int.zero
var creationOrder = Int.zero
var title: String!
var thumbnail: CGImage?
var thumbnail: CALayerContents?
var icon: CGImage? { get { application.icon } }
var shouldShowTheUser = true
var isTabbed: Bool = false
@@ -94,13 +94,13 @@ class Window {
CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes)
}
func refreshThumbnail(_ screenshot: CGImage) {
func refreshThumbnail(_ screenshot: CALayerContents) {
thumbnail = screenshot
if !App.app.appIsBeingUsed || !shouldShowTheUser { return }
if let position, let size,
let view = (ThumbnailsView.recycledViews.first { $0.window_?.cgWindowId == cgWindowId }) {
if !view.thumbnail.isHidden {
let thumbnailSize = ThumbnailView.thumbnailSize(screenshot, false)
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)
// if the thumbnail size has changed, we need to refresh the open UI
+4 -15
View File
@@ -339,21 +339,10 @@ class Windows {
}
}
guard (!eligibleWindows.isEmpty || windowRemoved) else { return }
screenshotEligibleWindowsAndUpdateUi(eligibleWindows, source)
}
private static func screenshotEligibleWindowsAndUpdateUi(_ eligibleWindows: [Window], _ source: RefreshCausedBy) {
for window in eligibleWindows {
BackgroundWork.screenshotsQueue.addOperation { [weak window] in
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
if let wid = window?.cgWindowId, let cgImage = wid.screenshot() {
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
DispatchQueue.main.async { [weak window] in
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
window?.refreshThumbnail(cgImage)
}
}
}
if #available(macOS 14.0, *) {
WindowCaptureScreenshots.oneTimeScreenshots(eligibleWindows, source)
} else {
WindowCaptureScreenshotsPrivateApi.oneTimeScreenshots(eligibleWindows, source)
}
}
+4 -1
View File
@@ -15,6 +15,7 @@ class AccessibilityEvents {
let position = try element.position()
DispatchQueue.main.async { [weak window] in
if let window = (window != nil ? window : (Windows.list.first { $0.isEqualRobust(element, wid) })) {
let needsRefresh = window.size != size || window.position != position || window.isMinimized != isMinimized || window.isFullscreen != isFullscreen
window.title = window.bestEffortTitle(title)
window.size = size
window.position = position
@@ -23,7 +24,9 @@ class AccessibilityEvents {
window.isFullscreen = isFullscreen
App.app.checkIfShortcutsShouldBeDisabled(window, nil)
}
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
if needsRefresh {
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
}
+1
View File
@@ -8,6 +8,7 @@ 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
+284
View File
@@ -0,0 +1,284 @@
import Cocoa
import ScreenCaptureKit
@available(macOS 14.0, *)
class WindowCaptureScreenshots {
// SCShareableContent.getExcludingDesktopWindows is expensive for the OS; we cache as much as possible
static var cachedSCWindows = [SCWindow]()
static func oneTimeScreenshots(_ windowsToScreenshot: [Window], _ source: RefreshCausedBy) {
let windows = windowsToScreenshot.compactMap { $0.cgWindowId }
guard !windows.isEmpty else { return }
let (cachedWindows, notCachedWindows) = sortCachedAndNotCached(windows)
Logger.debug { "cached:\(cachedWindows.map { $0.windowID }) notCached:\(notCachedWindows)" }
handleCachedWindows(cachedWindows, source)
handleNotCachedWindows(notCachedWindows, source)
}
private static func handleCachedWindows(_ cachedWindows: [SCWindow], _ source: RefreshCausedBy) {
guard !cachedWindows.isEmpty else { return }
for cachedWindow in cachedWindows {
oneTimeCapture(cachedWindow, source)
}
}
private static func handleNotCachedWindows(_ notCachedWindows: [CGWindowID], _ source: RefreshCausedBy) {
guard !notCachedWindows.isEmpty else { return }
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { shareableContent, error in
guard let shareableContent, error == nil else { Logger.error { "\(shareableContent == nil) \(error)" }; return }
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
// this callback is executed on an undetermined queue; we move execution to main-thread
DispatchQueue.main.async {
cachedSCWindows = shareableContent.windows
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
for notCachedWindow in notCachedWindows {
if let cachedWindow = (cachedSCWindows.first { $0.windowID == notCachedWindow }) {
oneTimeCapture(cachedWindow, source)
} else {
Logger.error { "wid:\(notCachedWindow) was not found in SCShareableContent windows" }
}
}
}
}
}
private static func sortCachedAndNotCached(_ windows: [CGWindowID]) -> ([SCWindow], [CGWindowID]) {
var cachedWindows = [SCWindow]()
var notCachedWindows = [CGWindowID]()
for window in windows {
if let cachedWindow = (cachedSCWindows.first { $0.windowID == window }) {
cachedWindows.append(cachedWindow)
} else {
notCachedWindows.append(window)
}
}
return (cachedWindows, notCachedWindows)
}
private static func oneTimeCapture(_ scWindow: SCWindow, _ source: RefreshCausedBy) {
let config = SCStreamConfiguration.forWindow(scWindow, false)
let filter = SCContentFilter(desktopIndependentWindow: scWindow)
SCScreenshotManager.captureSampleBuffer(contentFilter: filter, configuration: config) { sampleBuffer, error in
guard let sampleBuffer, error == nil else { Logger.error { "\(sampleBuffer == nil) \(error)" }; return }
SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { cgImage, error in
guard let cgImage, error == nil else { Logger.error { "\(cgImage == nil) \(error)" }; return }
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
DispatchQueue.main.async {
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
if let window = (Windows.list.first { $0.cgWindowId == scWindow.windowID }) {
window.refreshThumbnail(.cgImage(cgImage))
}
}
}
}
}
class WindowCaptureScreenshotsPrivateApi {
static func oneTimeScreenshots(_ eligibleWindows: [Window], _ source: RefreshCausedBy) {
for window in eligibleWindows {
BackgroundWork.screenshotsQueue.addOperation { [weak window] in
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
guard let wid = window?.cgWindowId, let cgImage = oneTimeCapture(wid) else { return }
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
DispatchQueue.main.async { [weak window] in
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
window?.refreshThumbnail(.cgImage(cgImage))
}
}
}
}
private static func oneTimeCapture(_ wid: CGWindowID) -> CGImage? {
// we use CGSHWCaptureWindowList because it can screenshot minimized windows, which CGWindowListCreateImage can't
var windowId_ = wid
let list = CGSHWCaptureWindowList(CGS_CONNECTION, &windowId_, 1, [.ignoreGlobalClipShape, .bestResolution, .fullSize]).takeRetainedValue() as! [CGImage]
return list.first
}
}
@available(macOS 12.3, *)
class WindowCaptureVideos {
private static var streams = [CGWindowID: SCStream]()
private static var streamOutputs = [CGWindowID: StreamOutput]()
// SCStream.backgroundColor is [unowned], so we must keep own these variables
static let scStreamBackgroundColorDark = NSColor(white: 0.23, alpha: 1).cgColor
static let scStreamBackgroundColorLight = NSColor.white.cgColor
static func startCapturing(_ windowsWhichMayHaveChanged: [Window]) {
let windowsToShow = Set<CGWindowID>(Windows.list.filter { !$0.isWindowlessApp && $0.shouldShowTheUser }.compactMap { $0.cgWindowId })
let windowsAlreadyStreaming = Set<CGWindowID>(streams.keys)
let windowsToStop = windowsAlreadyStreaming.subtracting(windowsToShow)
stopCaptures(windowsToStop)
let windowsToStart = windowsToShow.subtracting(windowsAlreadyStreaming)
let windowsWhichMayHaveChanged_ = windowsWhichMayHaveChanged.compactMap { $0.cgWindowId }
if !windowsToStart.isEmpty || !windowsWhichMayHaveChanged_.isEmpty {
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { shareableContent, error in
guard let shareableContent, error == nil else {
Logger.error { "\(shareableContent == nil) \(error)" }
return
}
// this callback is executed on an undetermined queue
// we move execution to main-thread to avoid races with starting/stopping streams and the app being shown/hidden
DispatchQueue.main.async {
guard App.app.appIsBeingUsed else { return }
startCaptures(windowsToStart, shareableContent)
updateCaptures(windowsWhichMayHaveChanged_, shareableContent)
Logger.debug { streams.keys }
}
}
}
}
static func stopCapturing() {
Logger.debug { streams.keys }
for stream in streams.values {
stream.stopCapture()
}
streams.removeAll()
streamOutputs.removeAll()
}
private static func updateCaptures(_ windowsWhichMayHaveChanged: [CGWindowID], _ shareableContent: SCShareableContent) {
for wid in windowsWhichMayHaveChanged {
if let stream = streams[wid],
let scWindow = shareableContent.windows.first(where: { $0.windowID == wid }) {
stream.updateConfiguration(SCStreamConfiguration.forWindow(scWindow, true)) { error in
if let error { Logger.error { error } }
}
}
}
}
private static func startCaptures(_ windowsToStart: Set<CGWindowID>, _ shareableContent: SCShareableContent) {
for wid in windowsToStart {
if let scWindow = shareableContent.windows.first(where: { $0.windowID == wid }) {
startCapture(scWindow)
}
}
}
private static func startCapture(_ window: SCWindow) {
let wid = window.windowID
let output = StreamOutput(wid)
let config = SCStreamConfiguration.forWindow(window, true)
let filter = SCContentFilter(desktopIndependentWindow: window)
let stream = SCStream(filter: filter, configuration: config, delegate: output)
do {
try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: BackgroundWork.screenshotsQueue.strongUnderlyingQueue)
stream.startCapture { error in
if let error { Logger.error { error } }
}
streams[wid] = stream
streamOutputs[wid] = output
} catch {
Logger.error { error }
}
}
private static func stopCaptures(_ windowsToStop: Set<CGWindowID>) {
for wid in windowsToStop {
stopCapture(wid)
}
}
private static func stopCapture(_ wid: CGWindowID) {
if let stream = streams[wid] {
stream.stopCapture()
streams.removeValue(forKey: wid)
streamOutputs.removeValue(forKey: wid)
}
}
class StreamOutput: NSObject, SCStreamOutput, SCStreamDelegate {
let wid: CGWindowID
init(_ wid: CGWindowID) {
self.wid = wid
}
// from SCStreamOutput; handle captured samples
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
BackgroundWork.screenshotsQueue.trackCallbacks {
if sampleBuffer.isValid,
let pixelBuffer = sampleBuffer.pixelBuffer() {
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.cgWindowId == self.wid }) {
window.refreshThumbnail(.pixelBuffer(pixelBuffer))
}
}
}
}
}
// from SCStreamDelegate; handle errors when opening a stream
func stream(_ stream: SCStream, didStopWithError error: any Error) {
BackgroundWork.screenshotsQueue.trackCallbacks {
Logger.error { error }
}
}
}
}
@available(macOS 12.3, *)
extension SCStreamConfiguration {
static func forWindow(_ window: SCWindow, _ video: Bool) -> SCStreamConfiguration {
let config = SCStreamConfiguration()
config.setWindowSize(window)
config.showsCursor = false
if video {
config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(60))
config.queueDepth = 8
// ~60% memory reduction compared to kCVPixelFormatType_32BGRA
config.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
// kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange doesn't have transparency, so we end up with an opaque background color around the window corners
// we use a background color that try and hide these corners as much as possible
config.backgroundColor = Appearance.currentTheme == .dark ? WindowCaptureVideos.scStreamBackgroundColorDark : WindowCaptureVideos.scStreamBackgroundColorLight
}
// config.scalesToFit = true
return config
}
private func setWindowSize(_ window: SCWindow) {
let originalSize = NSSize(width: window.frame.width * NSScreen.preferred.backingScaleFactor, height: window.frame.height * NSScreen.preferred.backingScaleFactor)
guard originalSize.width > 0, originalSize.height > 0 else { return }
if Preferences.previewFocusedWindow {
width = Int(originalSize.width)
height = Int(originalSize.height)
} else {
// capture screenshots as small as needed for the thumbnails
let maxSize = ThumbnailsPanel.maxPossibleThumbnailSize
guard maxSize.width > 0, maxSize.height > 0 else { return }
let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
width = Int((originalSize.width * scale).rounded())
height = Int((originalSize.height * scale).rounded())
}
}
}
extension CMSampleBuffer {
@available(macOS 12.3, *)
func pixelBuffer() -> CVPixelBuffer? {
if let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(self, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
let attachments = attachmentsArray.first,
let statusRawValue = attachments[SCStreamFrameInfo.status] as? Int,
let status = SCFrameStatus(rawValue: statusRawValue),
status == .complete || status == .started { // new frame was generated
return imageBuffer
}
return nil
}
@available(macOS 12.3, *)
func metalTexture(_ device: MTLDevice) -> MTLTexture? {
guard let pixelBuffer = pixelBuffer(),
let surface = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() else { return nil }
let desc = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .bgra8Unorm,
width: CVPixelBufferGetWidth(pixelBuffer),
height: CVPixelBufferGetHeight(pixelBuffer),
mipmapped: false
)
return device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
}
}
+1
View File
@@ -304,6 +304,7 @@ extension App: NSApplicationDelegate {
Spaces.refresh()
SpacesEvents.observe()
ScreensEvents.observe()
ThumbnailsPanel.updateMaxPossibleThumbnailSize()
SystemAppearanceEvents.observe()
SystemScrollerStyleEvents.observe()
Applications.initialDiscovery()
+1 -1
View File
@@ -35,7 +35,7 @@ final class DebugMenu: NSPanel {
while self.running {
var sample: [String: Double] = [:]
for queue in self.queues {
sample[queue.strongUnderlyingQueue.label] = Double(queue.operationCount)
sample[queue.strongUnderlyingQueue.label] = Double(queue.operationCount + queue.activeCallbacks)
}
DispatchQueue.main.async { self.graphView.addData(sample) }
Thread.sleep(forTimeInterval: 0.1)
+1 -1
View File
@@ -29,7 +29,7 @@ class FeedbackWindow: NSWindow {
private func setupView() {
let appIcon = LightImageView()
appIcon.updateWithResizedCopy(App.appIcon, NSSize(width: 80, height: 80))
appIcon.updateWithResizedCopy(.cgImage(App.appIcon), NSSize(width: 80, height: 80))
appIcon.fit(80, 80)
let appText = StackView([
BoldLabel(NSLocalizedString("Share improvement ideas, or report bugs", comment: "")),
+25 -22
View File
@@ -27,37 +27,40 @@ class LightImageView: NSView {
layerContentsRedrawPolicy = .never
}
func updateWithResizedCopy(_ image: CGImage?, _ size: NSSize) {
func updateWithResizedCopy(_ caLayerContents: CALayerContents, _ size: NSSize) {
let scaleFactor = NSScreen.preferred.backingScaleFactor
if let image {
// 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
// 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
switch caLayerContents {
case .cgImage(let image?):
layer!.contents = image
layer!.contentsScale = scaleFactor
case .pixelBuffer(let pixelBuffer?):
layer!.contents = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue()
default: break
}
layer!.contentsScale = scaleFactor
if frame.size != size {
frame.size = size
}
}
/// this schedules the image update for the next cycle
/// The Panel will show with previous pictures, then they each will get updated quickly. It's a trade-off of getting AltTab interactive faster at the price of wrong data being shown
/// I feel the UX is better without it. I may reconsider
func updateWithResizedCopyAsync(_ image: CGImage?, _ size: NSSize) {
layer!.contentsScale = NSScreen.preferred.backingScaleFactor
frame.size = size
if let image {
// set image on another cycle so it doesn't block initial rendering
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.layer!.contents = image
}
}
}
func releaseImage() {
layer!.contents = nil
}
}
enum CALayerContents {
case cgImage(CGImage?)
case pixelBuffer(CVPixelBuffer?)
func size() -> NSSize? {
switch self {
case .cgImage(let image):
return image?.size()
case .pixelBuffer(let pixelBuffer):
return pixelBuffer?.size()
}
}
}
+4 -4
View File
@@ -27,10 +27,10 @@ class PreviewPanel: NSPanel {
setAccessibilitySubrole(.unknown)
}
func show(_ id: CGWindowID, _ image: CGImage, _ position: CGPoint, _ size: CGSize) {
func show(_ id: CGWindowID, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) {
if id != currentId {
repositionAndResize(position, size)
previewView.updateWithResizedCopy(image, size)
previewView.updateWithResizedCopy(preview, size)
}
if id != currentId || !isVisible {
if Preferences.previewFadeInAnimation {
@@ -51,10 +51,10 @@ class PreviewPanel: NSPanel {
}
}
func updateIfShowing(_ id: CGWindowID?, _ image: CGImage, _ position: CGPoint, _ size: CGSize) {
func updateIfShowing(_ id: CGWindowID?, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) {
if isVisible && id == currentId {
repositionAndResize(position, size)
previewView.updateWithResizedCopy(image, size)
previewView.updateWithResizedCopy(preview, size)
}
}
+15 -15
View File
@@ -323,7 +323,7 @@ class ThumbnailView: FlippedView {
private func updateAppIcon(_ element: Window, _ title: String) {
let appIconSize = ThumbnailView.iconSize()
appIcon.updateWithResizedCopy(element.icon, appIconSize)
appIcon.updateWithResizedCopy(.cgImage(element.icon), appIconSize)
appIcon.setAccessibilityLabel(title)
}
@@ -341,12 +341,12 @@ class ThumbnailView: FlippedView {
))
if !thumbnail.isHidden {
if let screenshot = element.thumbnail {
let thumbnailSize = ThumbnailView.thumbnailSize(screenshot, false)
let thumbnailSize = ThumbnailView.thumbnailSize(element.size, false)
thumbnail.updateWithResizedCopy(screenshot, thumbnailSize)
} else {
// if no thumbnail, show appIcon instead
let thumbnailSize = ThumbnailView.thumbnailSize(element.icon, true)
thumbnail.updateWithResizedCopy(element.icon, thumbnailSize)
let thumbnailSize = ThumbnailView.thumbnailSize(element.icon?.size(), true)
thumbnail.updateWithResizedCopy(.cgImage(element.icon), thumbnailSize)
}
// for Accessibility > "speak items under the pointer"
thumbnail.setAccessibilityLabel(element.title)
@@ -372,8 +372,8 @@ class ThumbnailView: FlippedView {
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)
let windowlessIconSize = ThumbnailView.thumbnailSize(element.icon?.size(), true)
windowlessIcon.updateWithResizedCopy(.cgImage(element.icon), windowlessIconSize)
}
windowControlIcons.forEach { $0.window_ = element }
showOrHideWindowControls(isShowingWindowControls)
@@ -550,8 +550,8 @@ class ThumbnailView: FlippedView {
return shadow
}
static func maxThumbnailWidth() -> CGFloat {
return ThumbnailsPanel.maxThumbnailsWidth() * Appearance.windowMaxWidthInRow - Appearance.interCellPadding * 2
static func maxThumbnailWidth(_ screen: NSScreen = NSScreen.preferred) -> CGFloat {
return ThumbnailsPanel.maxThumbnailsWidth(screen) * Appearance.windowMaxWidthInRow - Appearance.interCellPadding * 2
}
static func widthOfComfortableReadability() -> CGFloat? {
@@ -582,14 +582,14 @@ class ThumbnailView: FlippedView {
/// The maximum height that a thumbnail can be drawn
/// maxThumbnailsHeight = maxThumbnailHeight * rowCount + interCellPadding * (rowCount - 1)
/// maxThumbnailHeight = (maxThumbnailsHeight - interCellPadding * (rowCount - 1)) / rowCount
static func maxThumbnailHeight() -> CGFloat {
return ((ThumbnailsPanel.maxThumbnailsHeight() - Appearance.interCellPadding) / Appearance.rowsCount - Appearance.interCellPadding).rounded()
static func maxThumbnailHeight(_ screen: NSScreen = NSScreen.preferred) -> CGFloat {
return ((ThumbnailsPanel.maxThumbnailsHeight(screen) - Appearance.interCellPadding) / Appearance.rowsCount - Appearance.interCellPadding).rounded()
}
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)
static func thumbnailSize(_ imageSize: NSSize?, _ isWindowlessApp: Bool) -> NSSize {
guard let imageSize else { return NSSize(width: 0, height: 0) }
let imageWidth = imageSize.width
let imageHeight = imageSize.height
let thumbnailHeightMax = ThumbnailView.maxThumbnailHeight()
- Appearance.edgeInsetsSize * 2
- Appearance.intraCellPadding
@@ -598,7 +598,7 @@ class ThumbnailView: FlippedView {
- Appearance.edgeInsetsSize * 2
// don't stretch very small windows; keep them 1:1 in the switcher
if !isWindowlessApp && imageWidth < thumbnailWidthMax && imageHeight < thumbnailHeightMax {
return NSSize(width: imageWidth, height: imageHeight)
return imageSize
}
let thumbnailHeight = min(imageHeight, thumbnailHeightMax)
let thumbnailWidth = min(imageWidth, thumbnailWidthMax)
+14 -5
View File
@@ -4,6 +4,7 @@ class ThumbnailsPanel: NSPanel {
var thumbnailsView = ThumbnailsView()
override var canBecomeKey: Bool { true }
private var didDisplayOnce = false
static var maxPossibleThumbnailSize = NSSize.zero
convenience init() {
self.init(contentRect: .zero, styleMask: .nonactivatingPanel, backing: .buffered, defer: false)
@@ -72,22 +73,30 @@ class ThumbnailsPanel: NSPanel {
thumbnailsView.scrollView.flashScrollers()
}
static func maxThumbnailsWidth() -> CGFloat {
static func maxThumbnailsWidth(_ screen: NSScreen = NSScreen.preferred) -> CGFloat {
if Preferences.appearanceStyle == .titles,
let readableWidth = ThumbnailView.widthOfComfortableReadability() {
return (
min(
NSScreen.preferred.frame.width * Appearance.maxWidthOnScreen,
screen.frame.width * Appearance.maxWidthOnScreen,
readableWidth + Appearance.intraCellPadding * 2 + Appearance.appIconLabelSpacing + Appearance.iconSize
// widthOfLongestTitle + Appearance.intraCellPadding * 2 + Appearance.appIconLabelSpacing + Appearance.iconSize
) - Appearance.windowPadding * 2
).rounded()
}
return (NSScreen.preferred.frame.width * Appearance.maxWidthOnScreen - Appearance.windowPadding * 2).rounded()
return (screen.frame.width * Appearance.maxWidthOnScreen - Appearance.windowPadding * 2).rounded()
}
static func maxThumbnailsHeight() -> CGFloat {
return (NSScreen.preferred.frame.height * Appearance.maxHeightOnScreen - Appearance.windowPadding * 2).rounded()
static func maxThumbnailsHeight(_ screen: NSScreen = NSScreen.preferred) -> CGFloat {
return (screen.frame.height * Appearance.maxHeightOnScreen - Appearance.windowPadding * 2).rounded()
}
static func updateMaxPossibleThumbnailSize() {
let (w, h) = NSScreen.screens.reduce((CGFloat.zero, CGFloat.zero)) { acc, screen in
(max(acc.0, ThumbnailView.maxThumbnailWidth(screen) * screen.backingScaleFactor),
max(acc.1, ThumbnailView.maxThumbnailHeight(screen) * screen.backingScaleFactor))
}
maxPossibleThumbnailSize = NSSize(width: w, height: h)
}
}
+1
View File
@@ -24,6 +24,7 @@ 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()
updateBackgroundView()
App.app.thumbnailsPanel.contentView = contentView
@@ -40,7 +40,7 @@ class PermissionsWindow: NSWindow {
private func setupView() {
let appIcon = LightImageView()
appIcon.updateWithResizedCopy(App.appIcon, NSSize(width: 80, height: 80))
appIcon.updateWithResizedCopy(.cgImage(App.appIcon), NSSize(width: 80, height: 80))
appIcon.fit(80, 80)
let appText = TitleLabel(NSLocalizedString("AltTab needs some permissions", comment: ""))
appText.preferredMaxLayoutWidth = 380
@@ -3,7 +3,7 @@ import Cocoa
class AboutTab {
static func initTab() -> NSView {
let appIcon = LightImageView()
appIcon.updateWithResizedCopy(App.appIcon, NSSize(width: 256, height: 256))
appIcon.updateWithResizedCopy(.cgImage(App.appIcon), NSSize(width: 256, height: 256))
appIcon.fit(256, 256)
let appText = StackView([
BoldLabel(App.name),