Compare commits

...
Author SHA1 Message Date
Suat Kocar c65d1e60f8 feat: add live preview using ScreenCaptureKit
Adds real-time video preview of windows instead of static thumbnails.
Uses ScreenCaptureKit (macOS 12.3+) for efficient window capture.

Features:
- Configurable quality (Thumbnail to Native)
- Configurable frame rate (5-120 FPS)
- Configurable scope (Selected Window/App/All Windows)
- Incremental stream management for performance
- CIContext reuse for efficient frame processing
2025-12-21 00:24:36 +01:00
19 changed files with 372 additions and 105 deletions

No files matched your search

+4
View File
@@ -59,6 +59,7 @@
1C961F9CF4F1BEDF04ACAEE0 /* Switch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C96189273B7482124C36F4C /* Switch.swift */; };
1C961FA2D2D99AE54EBECA67 /* titles_show_running_windows_light@2x.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 1C9610B2E76F1E429C8D3660 /* titles_show_running_windows_light@2x.jpg */; };
1C961FB59566B8314771F58E /* thumbnails_show_preview_focused_window_light@2x.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 1C961EF84B061A68FD9AEC46 /* thumbnails_show_preview_focused_window_light@2x.jpg */; };
346D342C2EEA72A4009590BE /* WindowCaptureEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346D342B2EEA72A4009590BE /* WindowCaptureEvents.swift */; };
442E26DB1F7DA80AF794CCB6 /* Pods_unit_tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C7E6F409CA442F83B7BE71AD /* Pods_unit_tests.framework */; };
4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4807A6C523A9CD190052A53E /* SkyLight.framework */; };
48F3E16224EC0D8800A1C64B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BABDA79F8EF76E3ACDD5F /* Localizable.strings */; };
@@ -349,6 +350,7 @@
1C961F5740647DBFA0540789 /* thumbnails_show_tabs_as_windows_light@2x.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "thumbnails_show_tabs_as_windows_light@2x.jpg"; sourceTree = "<group>"; };
1C961F68C77AAE38E5A2E046 /* app_icons_show_running_applications_light@2x.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "app_icons_show_running_applications_light@2x.jpg"; sourceTree = "<group>"; };
1C961FB3DCF4906FEEF4A23D /* TableGroupView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableGroupView.swift; sourceTree = "<group>"; };
346D342B2EEA72A4009590BE /* WindowCaptureEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCaptureEvents.swift; sourceTree = "<group>"; };
4807A6C523A9CD190052A53E /* SkyLight.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SkyLight.framework; path = ../../../../System/Library/PrivateFrameworks/SkyLight.framework; sourceTree = "<group>"; };
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>"; };
@@ -1895,6 +1897,7 @@
BF0C8643F524515224BF1618 /* KeyboardEventsTestable.swift */,
BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */,
BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */,
346D342B2EEA72A4009590BE /* WindowCaptureEvents.swift */,
);
path = events;
sourceTree = "<group>";
@@ -2434,6 +2437,7 @@
D04BA446D702C5E252AF2319 /* TitleLabel.swift in Sources */,
D04BAD43C122A1C7E88B0362 /* MouseEvents.swift in Sources */,
D04BAFF30A98CF287F85DA1E /* BlacklistsTab.swift in Sources */,
346D342C2EEA72A4009590BE /* WindowCaptureEvents.swift in Sources */,
D04BA56789A2AF52557A9BFF /* AppCenterCrashes.swift in Sources */,
D04BADA3F2DAB820D6FC6E75 /* AppCenterApplication.m in Sources */,
D04BAA16F21AC41A23DDA63D /* ControlsTab.swift in Sources */,
+4 -6
View File
@@ -64,9 +64,7 @@ extension AXUIElement {
// if their are common updates, we avoid congestion by only retrying the latest update
if let pid, callType == .updateWindow || callType == .updateAppWindows {
let unresponsiveAppsMapKey = "\(callType.rawValue)\(pid)"
axCallsRetriesQueueUnresponsiveAppsMap.lock.lock()
let time = axCallsRetriesQueueUnresponsiveAppsMap.map[unresponsiveAppsMapKey]
axCallsRetriesQueueUnresponsiveAppsMap.lock.unlock()
let time = axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] }
if let time {
if startTimeInNanoseconds > time {
// new most recent call; replace and retry
@@ -485,9 +483,9 @@ enum DebounceType: Int {
case windowTitleChanged
}
class ConcurrentMap<K: Hashable, V> {
var map = [K: V]()
let lock = NSLock()
final class ConcurrentMap<K: Hashable, V> {
private var map = [K: V]()
private let lock = NSLock()
@discardableResult
func withLock<T>(_ block: (inout [K: V]) -> T) -> T {
-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)
+14 -1
View File
@@ -207,7 +207,7 @@ extension NSImage {
extension CGImage {
func nsImage() -> NSImage {
return NSImage(cgImage: self, size: NSSize(width: width, height: height))
return NSImage(cgImage: self, size: size())
}
static func named(_ imageName: String) -> CGImage {
@@ -215,6 +215,19 @@ extension CGImage {
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, nil)!
return CGImageSourceCreateImageAtIndex(imageSource, 0, nil)!
}
func size() -> NSSize {
return NSSize(width: width, height: height)
}
}
extension CVPixelBuffer {
func size() -> NSSize {
NSSize(
width: CVPixelBufferGetWidth(self),
height: CVPixelBufferGetHeight(self)
)
}
}
extension pid_t {
+14 -1
View File
@@ -41,7 +41,7 @@ class BackgroundWork {
missionControlThread = BackgroundThreadWithRunLoop("missionControlThread", .userInteractive)
// we listen to CLI commands (CFMessagePort events)
cliEventsThread = BackgroundThreadWithRunLoop("cliEventsThread", .userInteractive)
// logThreadsAndQueuesOnRepeat()
// logThreadsAndQueuesOnRepeat()
}
static func startCrashReportsQueue() {
@@ -149,6 +149,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])
@@ -168,3 +172,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()
}
}
+2
View File
@@ -88,6 +88,7 @@ class Preferences {
"previewFocusedWindow": "false",
"screenRecordingPermissionSkipped": "false",
"trackpadHapticFeedbackEnabled": "true",
"videoThumbnailsAndPreview": "true",
]
// system preferences
@@ -125,6 +126,7 @@ class Preferences {
static var blacklist: [BlacklistEntry] { CachedUserDefaults.json("blacklist", [BlacklistEntry].self) }
static var previewFocusedWindow: Bool { CachedUserDefaults.bool("previewFocusedWindow") }
static var screenRecordingPermissionSkipped: Bool { CachedUserDefaults.bool("screenRecordingPermissionSkipped") }
static var videoThumbnailsAndPreview: Bool { CachedUserDefaults.bool("videoThumbnailsAndPreview") }
// macro values
static var appearanceStyle: AppearanceStylePreference { CachedUserDefaults.macroPref("appearanceStyle", AppearanceStylePreference.allCases) }
+5 -5
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
@@ -91,16 +91,16 @@ 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 size,
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)
view.thumbnail.updateWithResizedCopy(screenshot, thumbnailSize)
}
App.app.previewPanel.updateImageIfShowing(cgWindowId, screenshot, size)
App.app.previewPanel.updateIfShowing(cgWindowId, screenshot, position, size)
}
}
+12 -19
View File
@@ -139,7 +139,7 @@ class Windows {
}
if Windows.list.count > 0 {
moveFocusedWindowIndexAfterWindowDestroyedInBackground(index)
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent)
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent, windowRemoved: true)
} else {
App.app.hideUi()
}
@@ -328,8 +328,8 @@ class Windows {
}
// dispatch screenshot requests off the main-thread, then wait for completion
static func refreshThumbnailsAsync(_ windows: [Window], _ source: RefreshCausedBy) {
guard !windows.isEmpty && ScreenRecordingPermission.status == .granted
static func refreshThumbnailsAsync(_ windows: [Window], _ source: RefreshCausedBy, windowRemoved: Bool = false) {
guard (!windows.isEmpty || windowRemoved) && ScreenRecordingPermission.status == .granted
&& !Preferences.onlyShowApplications()
&& (!Appearance.hideThumbnails || Preferences.previewFocusedWindow) else { return }
var eligibleWindows = [Window]()
@@ -338,23 +338,16 @@ class Windows {
eligibleWindows.append(window)
}
}
if eligibleWindows.isEmpty { 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)
}
}
}
guard (!eligibleWindows.isEmpty || windowRemoved) else { return }
if Preferences.videoThumbnailsAndPreview && App.app.appIsBeingUsed, #available(macOS 12.3, *) {
WindowCaptureEvents.toggleOn(source != .refreshOnlyThumbnailsAfterShowUi ? eligibleWindows : [])
return
}
if !Preferences.videoThumbnailsAndPreview, #available(macOS 14.0, *) {
WindowCapture.oneTimeScreenshots(eligibleWindows, source)
return
}
WindowCapture.oneTimeScreenshotsPrivateApi(eligibleWindows, source)
}
static func refreshWhichWindowsToShowTheUser() {
+1 -1
View File
@@ -13,7 +13,7 @@ class AccessibilityEvents {
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
let size = try element.size()
let position = try element.position()
DispatchQueue.main.async {
DispatchQueue.main.async { [weak window] in
if let window = (window != nil ? window : (Windows.list.first { $0.isEqualRobust(element, wid) })) {
window.title = window.bestEffortTitle(title)
window.size = size
+240
View File
@@ -0,0 +1,240 @@
import Cocoa
import ScreenCaptureKit
@available(macOS 12.3, *)
class WindowCaptureEvents {
private static var streams = [CGWindowID: SCStream]()
private static var streamOutputs = [CGWindowID: StreamOutput]()
static func toggleOn(_ 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(error, shareableContent == nil)
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 toggleOff() {
for stream in streams.values {
stream.stopCapture { error in
if let error { Logger.error(error) }
}
}
streams.removeAll()
streamOutputs.removeAll()
Logger.debug()
}
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 { error in
if let error { Logger.error(error) }
}
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() {
let wid_ = wid
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.cgWindowId == 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.pixelFormat = kCVPixelFormatType_32BGRA
config.showsCursor = false
if video {
config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(60))
config.queueDepth = 8
}
// config.scalesToFit = true
return config
}
private func setWindowSize(_ window: SCWindow) {
let backingScaleFactor = Int(NSScreen.preferred.backingScaleFactor)
width = Int(window.frame.width) * backingScaleFactor
height = Int(window.frame.height) * backingScaleFactor
}
}
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
}
}
class WindowCapture {
@available(macOS 14.0, *)
static func oneTimeScreenshots(_ windowsToScreenshot: [Window], _ source: RefreshCausedBy) {
let windows = windowsToScreenshot.compactMap { $0.cgWindowId }
if !windows.isEmpty {
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { shareableContent, error in
guard let shareableContent, error == nil else {
Logger.error(error, shareableContent == nil)
return
}
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { 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 {
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
oneTimeCaptures(windows, shareableContent, source)
Logger.debug(windows)
}
}
}
}
@available(macOS 14.0, *)
private static func oneTimeCaptures(_ windows: [CGWindowID], _ shareableContent: SCShareableContent, _ source: RefreshCausedBy) {
for wid in windows {
if let scWindow = shareableContent.windows.first(where: { $0.windowID == wid }) {
oneTimeCapture(scWindow, source)
}
}
}
@available(macOS 14.0, *)
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
if let error {
Logger.error(error)
return
}
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
if let sampleBuffer, sampleBuffer.isValid, let pixelBuffer = sampleBuffer.imageBuffer {
let wid = scWindow.windowID
DispatchQueue.main.async {
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
if let window = (Windows.list.first { $0.cgWindowId == wid }) {
window.refreshThumbnail(.pixelBuffer(pixelBuffer))
}
}
}
}
}
static func oneTimeScreenshotsPrivateApi(_ 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 = oneTimeCapturePrivateApi(wid) {
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
DispatchQueue.main.async { [weak window] in
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
window?.refreshThumbnail(.cgImage(cgImage))
}
}
}
}
}
private static func oneTimeCapturePrivateApi(_ 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
}
}
+5 -2
View File
@@ -69,6 +69,9 @@ class App: AppCenterApplication {
}
hideAllTooltips()
MainMenu.toggle(enabled: true)
if #available(macOS 12.3, *), Preferences.videoThumbnailsAndPreview {
WindowCaptureEvents.toggleOff()
}
}
/// some tooltips may not be hidden when the main window is hidden; we force it through a private API
@@ -197,8 +200,8 @@ class App: AppCenterApplication {
CGWarpMouseCursorPosition(point)
}
func refreshOpenUi(_ windowsToScreenshot: [Window], _ source: RefreshCausedBy) {
Windows.refreshThumbnailsAsync(windowsToScreenshot, source)
func refreshOpenUi(_ windowsToScreenshot: [Window], _ source: RefreshCausedBy, windowRemoved: Bool = false) {
Windows.refreshThumbnailsAsync(windowsToScreenshot, source, windowRemoved: windowRemoved)
guard appIsBeingUsed else { return }
if source == .refreshUiAfterExternalEvent {
if !Windows.updatesBeforeShowing() { hideUi(); return }
+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 -24
View File
@@ -3,8 +3,6 @@ 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")
}
@@ -28,35 +26,38 @@ class LightImageView: NSView {
]
}
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
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()
}
}
}
+17 -12
View File
@@ -27,20 +27,10 @@ class PreviewPanel: NSPanel {
setAccessibilitySubrole(.unknown)
}
func updateImageIfShowing(_ id: CGWindowID?, _ preview: CGImage, _ size: CGSize) {
if isVisible && id == currentId {
previewView.updateWithResizedCopy(preview, size)
}
}
func show(_ id: CGWindowID, _ preview: CGImage, _ position: CGPoint, _ size: CGSize) {
func show(_ id: CGWindowID, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) {
if id != currentId {
repositionAndResize(position, size)
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
frame.origin.y = NSScreen.screens.first!.frame.maxY - frame.maxY
setFrame(frame, display: false)
}
if id != currentId || !isVisible {
if Preferences.previewFadeInAnimation {
@@ -60,6 +50,21 @@ class PreviewPanel: NSPanel {
level = NSWindow.Level(rawValue: App.app.thumbnailsPanel.level.rawValue - 1)
}
}
func updateIfShowing(_ id: CGWindowID?, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) {
if isVisible && id == currentId {
repositionAndResize(position, size)
previewView.updateWithResizedCopy(preview, size)
}
}
private func repositionAndResize( _ position: CGPoint, _ size: CGSize) {
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
frame.origin.y = NSScreen.screens.first!.frame.maxY - frame.maxY
setFrame(frame, display: false)
}
}
private class BorderView: NSView {
+11 -11
View File
@@ -317,7 +317,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)
}
@@ -335,12 +335,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)
@@ -366,8 +366,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)
@@ -580,10 +580,10 @@ class ThumbnailView: FlippedView {
return ((ThumbnailsPanel.maxThumbnailsHeight() - 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
@@ -592,7 +592,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)
@@ -37,7 +37,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),
@@ -364,6 +364,7 @@ extension Popover: NSPopoverDelegate {
}
class AppearanceTab: NSObject {
static var livePreviewOptionRows = [TableGroupView.RowInfo]()
static var customizeStyleButton: NSButton!
static var animationsButton: NSButton!
static var customizeStyleSheet: CustomizeStyleSheet!
@@ -380,7 +381,8 @@ class AppearanceTab: NSObject {
private static func makeView() -> NSStackView {
let appearanceView = makeAppearanceView()
let multipleScreensView = makeMultipleScreensView()
let view = TableGroupSetView(originalViews: [appearanceView, multipleScreensView, animationsButton])
let livePreviewView = makeLivePreviewView()
let view = TableGroupSetView(originalViews: [appearanceView, multipleScreensView, livePreviewView, animationsButton])
view.translatesAutoresizingMaskIntoConstraints = false
view.widthAnchor.constraint(equalToConstant: view.fittingSize.width).isActive = true
return view
@@ -410,6 +412,17 @@ class AppearanceTab: NSObject {
return table
}
private static func makeLivePreviewView() -> NSView {
livePreviewOptionRows.removeAll()
let table = TableGroupView(title: NSLocalizedString("Video thumbnails and preview", comment: ""),
width: PreferencesWindow.width)
let enableSwitch = LabelAndControl.makeSwitch("videoThumbnailsAndPreview")
_ = table.addRow(leftText: NSLocalizedString("Enable for video. Disable for image", comment: ""),
rightViews: [enableSwitch])
table.fit()
return table
}
private static func getCustomizeStyleButtonTitle() -> String {
if Preferences.appearanceStyle == .thumbnails {
return NSLocalizedString("Customize Thumbnails style…", comment: "")