feat: switcher displays faster; other performance improvements

This commit is contained in:
lwouis committed 2025-01-02 01:55:27 +01:00
1 parent 7207ad6da7
commit b51ff6583d
20 files changed
+563 -493

No files matched your search

-2
View File
@@ -3140,7 +3140,6 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
SWIFT_VERSION = 5.0;
};
name = Release;
};
@@ -3206,7 +3205,6 @@
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
+1 -1
View File
@@ -3,7 +3,7 @@
PRODUCT_NAME = AltTab
PRODUCT_BUNDLE_IDENTIFIER = com.lwouis.alt-tab-macos
MACOSX_DEPLOYMENT_TARGET = 10.12
SWIFT_VERSION = 5.8
SWIFT_VERSION = 5.8 // xcode 14.2 translates this to 5.0; xcode 16.2 as 5.8
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
INFOPLIST_FILE = Info.plist
CODE_SIGN_ENTITLEMENTS = alt_tab_macos.entitlements
+1 -1
View File
@@ -15,7 +15,7 @@ extension CGWindowID {
return CGSCopySpacesForWindows(cgsMainConnectionId, CGSSpaceMask.all.rawValue, [self] as CFArray) as! [CGSSpaceID]
}
func screenshot(_ bestResolution: Bool = false) -> CGImage? {
func screenshot(_ bestResolution: Bool) -> CGImage? {
// CGSHWCaptureWindowList
var windowId_ = self
let list = CGSHWCaptureWindowList(cgsMainConnectionId, &windowId_, 1, [.ignoreGlobalClipShape, bestResolution ? .bestResolution : .nominalResolution]).takeRetainedValue() as! [CGImage]
+46 -15
View File
@@ -96,24 +96,46 @@ extension NSView {
anchor.constraint(equalToConstant: constant).isActive = true
}
}
func centerFrameInParent(x: Bool = false, y: Bool = false) {
let selfSize = (self is NSTextField) ? (self as! NSTextField).cell!.cellSize : frame.size
let superviewSize = (superview! is NSTextField) ? (superview! as! NSTextField).cell!.cellSize : superview!.frame.size
if (x) {
frame.origin.x = ((superviewSize.width - selfSize.width) / 2).rounded()
}
if (y) {
let diff = superviewSize.height - selfSize.height
// if there is no perfect centering, we biais top, as it's more aesthetic for ThumbnailView.label
let diffWithBiasTop = diff.truncatingRemainder(dividingBy: 2) == 0 ? diff : diff - 1
frame.origin.y = (diffWithBiasTop / 2).rounded()
}
}
func setSubviews(_ views: [NSView]) {
for view in views {
normalizeSubview(view)
}
subviews = views
}
func addSubviews(_ views: [NSView]) {
for view in views {
normalizeSubview(view)
}
subviews = subviews + views
}
func setSubviewAbove(_ view: NSView) {
normalizeSubview(view)
addSubview(view, positioned: .above, relativeTo: nil)
}
private func normalizeSubview(_ view: NSView) {
view.translatesAutoresizingMaskIntoConstraints = false
}
}
extension Collection {
// forEach with each iteration run concurrently on the global queue
func forEachAsync(fn: @escaping (Element) -> Void) {
let group = DispatchGroup()
for element in self {
backgroundWorkGlobalSemaphore.wait()
BackgroundWork.mainQueueConcurrentWorkQueue.async(group: group) {
group.enter()
fn(element)
backgroundWorkGlobalSemaphore.signal()
group.leave()
}
}
group.wait()
}
subscript(safe index: Index) -> Element? {
return indices.contains(index) ? self[index] : nil
}
@@ -199,6 +221,10 @@ extension NSImage {
img.unlockFocus()
return img
}
static func fromCgImage(_ cgImage: CGImage) -> NSImage {
return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
}
}
extension pid_t {
@@ -257,6 +283,11 @@ class ModifierFlags {
}
extension NSPoint {
static func += (lhs: inout NSPoint, rhs: NSPoint) {
lhs.x += rhs.x
lhs.y += rhs.y
}
static func + (lhs: NSPoint, rhs: NSPoint) -> NSPoint {
return NSPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}
+6 -5
View File
@@ -46,7 +46,7 @@ class Application: NSObject {
func removeWindowslessAppWindow() {
if let windowlessAppWindow = (Windows.list.firstIndex { $0.isWindowlessApp == true && $0.application.pid == pid }) {
Windows.list.remove(at: windowlessAppWindow)
App.app.refreshOpenUi()
App.app.refreshOpenUi([])
}
}
@@ -98,8 +98,9 @@ class Application: NSObject {
if (!atLeastOneActualWindow) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let window = self.addWindowslessAppsIfNeeded()
App.app.refreshOpenUi(window)
if self.addWindowlessWindowIfNeeded() != nil {
App.app.refreshOpenUi([])
}
}
// workaround: some apps launch but take a while to create their window(s)
// initial windows don't trigger a windowCreated notification, so we won't get notified
@@ -113,14 +114,14 @@ class Application: NSObject {
}
}
func addWindowslessAppsIfNeeded() -> [Window]? {
func addWindowlessWindowIfNeeded() -> Window? {
if !Preferences.hideWindowlessApps &&
runningApplication.activationPolicy == .regular &&
!runningApplication.isTerminated &&
(Windows.list.firstIndex { $0.application.pid == pid }) == nil {
let window = Window(self)
Windows.appendAndUpdateFocus(window)
return [window]
return window
}
return nil
}
+21 -12
View File
@@ -56,26 +56,35 @@ class Applications {
}
}
static func removeRunningApplications(_ runningApps: [NSRunningApplication]) {
static func removeRunningApplications(_ terminatingApps: [NSRunningApplication]) {
let existingAppsToRemove = list.filter { app in terminatingApps.contains { tApp in app.runningApplication.isEqual(tApp) } }
let existingWindowstoRemove = Windows.list.filter { window in terminatingApps.contains { tApp in window.application.runningApplication.isEqual(tApp) } }
if existingAppsToRemove.isEmpty && existingWindowstoRemove.isEmpty { return }
var windowsOnTheLeftOfFocusedWindow = 0
for runningApp in runningApps {
// comparing pid here can fail here, as it can be already nil; we use isEqual here to avoid the issue
Applications.list.removeAll { $0.runningApplication.isEqual(runningApp) }
Windows.list.enumerated().forEach { (index, window) in
if window.application.runningApplication.isEqual(runningApp) && index < Windows.focusedWindowIndex && window.shouldShowTheUser {
for tApp in terminatingApps {
for (index, window) in Windows.list.enumerated() {
if window.application.runningApplication.isEqual(tApp)
&& index < Windows.focusedWindowIndex && window.shouldShowTheUser {
windowsOnTheLeftOfFocusedWindow += 1
}
}
Windows.list.removeAll { $0.application.runningApplication.isEqual(runningApp) }
// comparing pid here can fail here, as it can be already nil; we use isEqual here to avoid the issue
Applications.list.removeAll { $0.runningApplication.isEqual(tApp) }
Windows.list.removeAll { $0.application.runningApplication.isEqual(tApp) }
}
if Windows.list.count == 0 { App.app.hideUi(); return }
if windowsOnTheLeftOfFocusedWindow > 0 {
Windows.cycleFocusedWindowIndex(-windowsOnTheLeftOfFocusedWindow)
if Windows.list.count == 0 {
App.app.hideUi()
} else {
if windowsOnTheLeftOfFocusedWindow > 0 {
Windows.cycleFocusedWindowIndex(-windowsOnTheLeftOfFocusedWindow)
}
if !existingWindowstoRemove.isEmpty {
App.app.refreshOpenUi([])
}
}
App.app.refreshOpenUi()
}
static func refreshBadges() {
static func refreshBadgesAsync() {
if !App.app.appIsBeingUsed || Preferences.hideAppBadges { return }
retryAxCallUntilTimeout {
if let dockPid = (list.first { $0.runningApplication.bundleIdentifier == "com.apple.dock" }?.pid),
+4 -3
View File
@@ -2,7 +2,7 @@ import Foundation
// queues and dedicated threads to observe background events such as keyboard inputs, or accessibility events
class BackgroundWork {
static var mainQueueConcurrentWorkQueue: DispatchQueue!
static var screenshotsQueue: DispatchQueue!
static var accessibilityCommandsQueue: DispatchQueue!
static var axCallsQueue: DispatchQueue!
static var crashReportsQueue: DispatchQueue!
@@ -11,11 +11,12 @@ class BackgroundWork {
static var systemPermissionsThread: BackgroundThreadWithRunLoop!
static var repeatingKeyThread: BackgroundThreadWithRunLoop!
static var missionControlThread: BackgroundThreadWithRunLoop!
static let screenshotsDispatchGroup = DispatchGroup()
// swift static variables are lazy; we artificially force the threads to init
static func start() {
// TODO: clarify how this works
mainQueueConcurrentWorkQueue = DispatchQueue.globalConcurrent("mainQueueConcurrentWorkQueue", .userInteractive)
// screenshots are taken off the main thread, concurrently
screenshotsQueue = DispatchQueue.globalConcurrent("screenshotsQueue", .userInteractive)
// calls to act on windows (e.g. AXUIElementSetAttributeValue, AXUIElementPerformAction) are done off the main thread
accessibilityCommandsQueue = DispatchQueue.globalConcurrent("accessibilityCommandsQueue", .userInteractive)
// calls to the AX APIs are blocking. We dispatch those on a globalConcurrent queue
-5
View File
@@ -30,11 +30,6 @@ class Spaces {
updateCurrentSpace()
}
static func refreshSpacesAndWindows() {
Spaces.refresh()
App.app.refreshOpenUi(Windows.list)
}
private static func updateCurrentSpace() {
// it seems that in some rare scenarios, some of these values are nil; we wrap to avoid crashing
if let mainScreen = NSScreen.main,
+16 -35
View File
@@ -7,7 +7,6 @@ class Window {
var creationOrder = Int.zero
var title: String!
var thumbnail: NSImage?
var thumbnailFullSize: NSSize?
var icon: NSImage? { get { application.icon } }
var shouldShowTheUser = true
var isTabbed: Bool = false
@@ -49,9 +48,6 @@ class Window {
self.title = bestEffortTitle(axTitle)
Window.globalCreationCounter += 1
self.creationOrder = Window.globalCreationCounter
if !Appearance.hideThumbnails {
refreshThumbnail()
}
application.removeWindowslessAppWindow()
checkIfFocused(application, wid)
Logger.debug("Adding window", cgWindowId ?? "nil", title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil")
@@ -100,23 +96,26 @@ class Window {
CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes)
}
private func screenshot(_ bestResolution: Bool = false) -> NSImage? {
guard !isWindowlessApp, let cgWindowId = cgWindowId, cgWindowId != CGWindowID(bitPattern: -1), let cgImage = cgWindowId.screenshot(bestResolution) else {
return nil
}
return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
}
func refreshThumbnail() {
guard let screenshot = screenshot() else {
return
}
func refreshThumbnail(_ screenshot: NSImage?) {
thumbnail = screenshot
thumbnailFullSize = thumbnail!.size
if App.app.appIsBeingUsed && shouldShowTheUser {
if let index = (Windows.list.firstIndex { $0.cgWindowId == cgWindowId }) {
let view = ThumbnailsView.recycledViews[index]
if !view.thumbnail.isHidden {
view.thumbnail.image = thumbnail
let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail, NSScreen.preferred(), false)
view.thumbnail.image?.size = thumbnailSize
view.thumbnail.frame.size = thumbnailSize
}
}
}
}
func getPreview() -> NSImage? {
return screenshot(true)
if let cgImage = cgWindowId?.screenshot(true) {
return NSImage.fromCgImage(cgImage)
}
return nil
}
func canBeClosed() -> Bool {
@@ -240,24 +239,6 @@ class Window {
return application.runningApplication.localizedName ?? ""
}
func updatesWindowSpace() {
// macOS bug: if you tab a window, then move the tab group to another space, other tabs from the tab group will stay on the current space
// you can use the Dock to focus one of the other tabs and it will teleport that tab in the current space, proving that it's a macOS bug
// note: for some reason, it behaves differently if you minimize the tab group after moving it to another space
if let cgWindowId = cgWindowId {
let spaceIds = cgWindowId.spaces()
if spaceIds.count == 1 {
spaceId = spaceIds.first!
spaceIndex = Spaces.idsAndIndexes.first { $0.0 == spaceIds.first! }!.1
isOnAllSpaces = false
} else if spaceIds.count > 1 {
spaceId = Spaces.currentSpaceId
spaceIndex = Spaces.currentSpaceIndex
isOnAllSpaces = true
}
}
}
func isOnScreen(_ screen: NSScreen) -> Bool {
if NSScreen.screensHaveSeparateSpaces {
if let screenUuid = screen.uuid(), let screenSpaces = Spaces.screenSpacesMap[screenUuid] {
+82 -31
View File
@@ -223,22 +223,18 @@ class Windows {
/// tabs detection is a flaky work-around the lack of public API to observe OS tabs
/// see: https://github.com/lwouis/alt-tab-macos/issues/1540
static func detectTabbedWindows() {
lazy var cgsWindowIds = Spaces.windowsInSpaces(Spaces.idsAndIndexes.map { $0.0 })
lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(Spaces.idsAndIndexes.map { $0.0 }, false)
list.forEach {
if let cgWindowId = $0.cgWindowId {
if $0.isMinimized || $0.isHidden {
if #available(macOS 13.0, *) {
// not exact after window merging
$0.isTabbed = !cgsWindowIds.contains(cgWindowId)
} else {
// not known
$0.isTabbed = false
}
private static func detectTabbedWindows(_ window: Window, _ cgsWindowIds: [CGWindowID], _ visibleCgsWindowIds: [CGWindowID]) {
if let cgWindowId = window.cgWindowId {
if window.isMinimized || window.isHidden {
if #available(macOS 13.0, *) {
// not exact after window merging
window.isTabbed = !cgsWindowIds.contains(cgWindowId)
} else {
$0.isTabbed = !visibleCgsWindowIds.contains(cgWindowId)
// not known
window.isTabbed = false
}
} else {
window.isTabbed = !visibleCgsWindowIds.contains(cgWindowId)
}
}
}
@@ -262,30 +258,85 @@ class Windows {
}
}
static func refreshThumbnailsAsync(_ screen: NSScreen, _ currentIndex: Int) {
DispatchQueue.main.async {
if !App.app.appIsBeingUsed || Appearance.hideThumbnails { return }
BackgroundWork.mainQueueConcurrentWorkQueue.async {
if currentIndex < list.count {
let window = list[currentIndex]
if window.shouldShowTheUser && !window.isWindowlessApp {
window.refreshThumbnail()
}
refreshThumbnailsAsync(screen, currentIndex + 1)
} else {
DispatchQueue.main.async { App.app.refreshOpenUi() }
static func updatesBeforeShowing(_ screen: NSScreen) -> Bool {
if list.count == 0 || MissionControl.state() == .showAllWindows || MissionControl.state() == .showFrontWindows { return false }
// TODO: find a way to update space info when spaces are changed, instead of on every trigger
// workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked,
// switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually
Spaces.refresh()
let spaceIdsAndIndexes = Spaces.idsAndIndexes.map { $0.0 }
lazy var cgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes)
lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false)
for window in list {
// TODO: can the CGS call inside detectTabbedWindows introduce latency when WindowServer is busy?
detectTabbedWindows(window, cgsWindowIds, visibleCgsWindowIds)
updatesWindowSpace(window)
refreshIfWindowShouldBeShownToTheUser(window, screen)
}
refreshWhichWindowsToShowTheUser(screen)
reorderList()
if (!list.contains { $0.shouldShowTheUser }) { return false }
return true
}
private static func updatesWindowSpace(_ window: Window) {
// macOS bug: if you tab a window, then move the tab group to another space, other tabs from the tab group will stay on the current space
// you can use the Dock to focus one of the other tabs and it will teleport that tab in the current space, proving that it's a macOS bug
// note: for some reason, it behaves differently if you minimize the tab group after moving it to another space
if let cgWindowId = window.cgWindowId {
let spaceIds = cgWindowId.spaces()
if spaceIds.count == 1 {
window.spaceId = spaceIds.first!
window.spaceIndex = Spaces.idsAndIndexes.first { $0.0 == spaceIds.first! }!.1
window.isOnAllSpaces = false
} else if spaceIds.count > 1 {
window.spaceId = Spaces.currentSpaceId
window.spaceIndex = Spaces.currentSpaceIndex
window.isOnAllSpaces = true
}
}
}
// dispatch screenshot requests off the main-thread, then wait for completion
static func refreshThumbnails(_ windows: [Window], _ bestResolution: Bool, _ onlyUpdateScreenshots: Bool) {
if Appearance.hideThumbnails { return }
var eligibleWindows = [Window]()
for window in windows {
if !window.isWindowlessApp, let cgWindowId = window.cgWindowId, cgWindowId != CGWindowID(bitPattern: -1) {
eligibleWindows.append(window)
}
}
if eligibleWindows.isEmpty { return }
screenshotEligibleWindowsAndRefreshUi(eligibleWindows, bestResolution, onlyUpdateScreenshots)
}
private static func screenshotEligibleWindowsAndRefreshUi(_ eligibleWindows: [Window], _ bestResolution: Bool, _ onlyUpdateScreenshots: Bool) {
eligibleWindows.forEach { _ in BackgroundWork.screenshotsDispatchGroup.enter() }
for window in eligibleWindows {
BackgroundWork.screenshotsQueue.async { [weak window] in
backgroundWorkGlobalSemaphore.wait()
defer {
backgroundWorkGlobalSemaphore.signal()
BackgroundWork.screenshotsDispatchGroup.leave()
}
if let cgImage = window?.cgWindowId?.screenshot(bestResolution) {
DispatchQueue.main.async { [weak window] in
window?.refreshThumbnail(NSImage.fromCgImage(cgImage))
}
}
}
}
if !onlyUpdateScreenshots {
BackgroundWork.screenshotsDispatchGroup.notify(queue: DispatchQueue.main) {
App.app.refreshOpenUi([])
}
}
}
static func refreshWhichWindowsToShowTheUser(_ screen: NSScreen) {
Windows.list.forEach { (window: Window) in
refreshIfWindowShouldBeShownToTheUser(window, screen)
}
if Preferences.onlyShowApplications() {
// Group windows by application and select the optimal main window
let windowsGroupedByApp = Dictionary(grouping: Windows.list) { $0.application.pid }
let windowsGroupedByApp = Dictionary(grouping: list) { $0.application.pid }
windowsGroupedByApp.forEach { (app, windows) in
if windows.count > 1, let mainWindow = selectMainWindow(windows) {
windows.forEach { window in
@@ -298,7 +349,7 @@ class Windows {
}
}
static func refreshIfWindowShouldBeShownToTheUser(_ window: Window, _ screen: NSScreen) {
private static func refreshIfWindowShouldBeShownToTheUser(_ window: Window, _ screen: NSScreen) {
window.shouldShowTheUser =
!(window.application.runningApplication.bundleIdentifier.flatMap { id in
Preferences.blacklist.contains {
+9 -9
View File
@@ -42,9 +42,10 @@ fileprivate func applicationActivated(_ element: AXUIElement, _ pid: pid_t) thro
let window = (appFocusedWindow != nil && wid != nil) ? Windows.updateLastFocus(appFocusedWindow!, wid!)?.first : nil
app.focusedWindow = window
App.app.checkIfShortcutsShouldBeDisabled(window, app.runningApplication)
App.app.refreshOpenUi(window != nil ? [window!] : nil)
App.app.refreshOpenUi(window != nil ? [window!] : [])
}
}
}
fileprivate func applicationHiddenOrShown(_ pid: pid_t, _ type: String) throws {
@@ -99,6 +100,11 @@ fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) thro
let position = try element.position()
let size = try element.size()
DispatchQueue.main.async {
// if the window is shown by alt-tab, we mark her as focused for this app
// this avoids issues with dialogs, quicklook, etc (see scenarios from #1044 and #2003)
if let w = (Windows.list.first { $0.isEqualRobust(element, wid) }) {
Applications.find(pid)?.focusedWindow = w
}
if let windows = Windows.updateLastFocus(element, wid) {
App.app.refreshOpenUi(windows)
} else if AXUIElement.isActualWindow(runningApp, wid, level, axTitle, subrole, role, size),
@@ -107,11 +113,6 @@ fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) thro
Windows.appendAndUpdateFocus(window)
App.app.refreshOpenUi([window])
}
// if the window is shown by alt-tab, we mark her as focused for this app
// this avoids issues with dialogs, quicklook, etc (see scenarios from #1044 and #2003)
if let w = (Windows.list.first { $0.isEqualRobust(element, wid) }) {
Applications.find(pid)?.focusedWindow = w
}
}
}
DispatchQueue.main.async {
@@ -137,13 +138,12 @@ fileprivate func windowDestroyed(_ element: AXUIElement, _ pid: pid_t) throws {
if let index = (Windows.list.firstIndex { $0.isEqualRobust(element, wid) }) {
let window = Windows.list[index]
Windows.removeAndUpdateFocus(window)
let windowlessApp = window.application.addWindowslessAppsIfNeeded()
if windowlessApp != nil {
if window.application.addWindowlessWindowIfNeeded() != nil {
Applications.find(pid)?.focusedWindow = nil
}
if Windows.list.count > 0 {
Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(index)
App.app.refreshOpenUi(windowlessApp)
App.app.refreshOpenUi([])
} else {
App.app.hideUi()
}
+1 -1
View File
@@ -8,7 +8,7 @@ class ScreensEvents {
@objc private static func handleEvent(_ notification: Notification) {
Logger.debug(notification.name.rawValue)
// a screen added or removed can shuffle windows around Spaces; we refresh them
Spaces.refreshSpacesAndWindows()
App.app.refreshOpenUi(Windows.list)
Logger.info("screens", NSScreen.screens.map { ($0.uuid() ?? "nil" as CFString, $0.frame) })
Logger.info("spaces", Spaces.screenSpacesMap)
Logger.info("current space", Spaces.currentSpaceIndex, Spaces.currentSpaceId)
+1 -1
View File
@@ -8,7 +8,7 @@ class SpacesEvents {
@objc private static func handleEvent(_ notification: Notification) {
Logger.debug(notification.name.rawValue)
// if UI was kept open during Space transition, the Spaces may be obsolete; we refresh them
Spaces.refreshSpacesAndWindows()
App.app.refreshOpenUi(Windows.list)
Logger.info("current space", Spaces.currentSpaceIndex, Spaces.currentSpaceId)
// from macos 12.2 beta onwards, we can't get other-space windows; grabbing windows when switching spaces mitigates the issue
// also, updating windows on Space transition works around an issue with Safari where its fullscreen windows spawn not in fullscreen.
+21 -39
View File
@@ -250,17 +250,17 @@ class App: AppCenterApplication, NSApplicationDelegate {
CGWarpMouseCursorPosition(point)
}
func refreshOpenUi(_ windowsToUpdate: [Window]? = nil) {
func refreshOpenUi(_ windowsToScreenshot: [Window], skipUpdatesBeforeShowing: Bool = false, onlyUpdateScreenshots: Bool = false) {
if !Appearance.hideThumbnails && !windowsToScreenshot.isEmpty {
Windows.refreshThumbnails(windowsToScreenshot, false, onlyUpdateScreenshots)
return
}
guard appIsBeingUsed else { return }
let currentScreen = NSScreen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen)
// workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked,
// switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually
Spaces.refresh()
if !skipUpdatesBeforeShowing {
if !Windows.updatesBeforeShowing(currentScreen) { hideUi(); return }
}
guard appIsBeingUsed else { return }
refreshSpecificWindows(windowsToUpdate, currentScreen)
if (!Windows.list.contains { $0.shouldShowTheUser }) { hideUi(); return }
guard appIsBeingUsed else { return }
Windows.reorderList()
Windows.updateFocusedWindowIndex()
guard appIsBeingUsed else { return }
thumbnailsPanel.thumbnailsView.updateItemsAndLayout(currentScreen)
@@ -269,16 +269,12 @@ class App: AppCenterApplication, NSApplicationDelegate {
thumbnailsPanel.display()
guard appIsBeingUsed else { return }
currentScreen.repositionPanel(thumbnailsPanel)
guard appIsBeingUsed else { return }
Windows.voiceOverWindow() // at this point ThumbnailViews are assigned to the window, and ready
}
private func refreshSpecificWindows(_ windowsToUpdate: [Window]?, _ currentScreen: NSScreen) -> ()? {
windowsToUpdate?.forEach { (window: Window) in
guard appIsBeingUsed else { return }
if !Appearance.hideThumbnails { window.refreshThumbnail() }
Windows.refreshIfWindowShouldBeShownToTheUser(window, currentScreen)
window.updatesWindowSpace()
}
guard appIsBeingUsed else { return }
Windows.previewFocusedWindowIfNeeded()
guard appIsBeingUsed else { return }
Applications.refreshBadgesAsync()
}
func showUiOrCycleSelection(_ shortcutIndex: Int) {
@@ -286,27 +282,17 @@ class App: AppCenterApplication, NSApplicationDelegate {
App.app.appIsBeingUsed = true
if isFirstSummon || shortcutIndex != self.shortcutIndex {
isFirstSummon = false
if Windows.list.count == 0 || MissionControl.state() == .showAllWindows || MissionControl.state() == .showFrontWindows { hideUi(); return }
// TODO: can the CGS call inside detectTabbedWindows introduce latency when WindowServer is busy?
Windows.detectTabbedWindows()
// TODO: find a way to update space info when spaces are changed, instead of on every trigger
// workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked,
// switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually
Spaces.refresh()
Windows.list.forEachAsync { $0.updatesWindowSpace() }
let screen = NSScreen.preferred()
self.shortcutIndex = shortcutIndex
Windows.refreshWhichWindowsToShowTheUser(screen)
Windows.reorderList()
if (!Windows.list.contains { $0.shouldShowTheUser }) { hideUi(); return }
let screen = NSScreen.preferred()
if !Windows.updatesBeforeShowing(screen) { hideUi(); return }
Windows.setInitialFocusedAndHoveredWindowIndex()
if Preferences.windowDisplayDelay == DispatchTimeInterval.milliseconds(0) {
self.rebuildUi(screen)
buildUiAndShowPanel(screen)
} else {
delayedDisplayScheduled += 1
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay) { () -> () in
if self.delayedDisplayScheduled == 1 {
self.rebuildUi(screen)
self.buildUiAndShowPanel(screen)
}
self.delayedDisplayScheduled -= 1
}
@@ -317,21 +303,17 @@ class App: AppCenterApplication, NSApplicationDelegate {
}
}
func rebuildUi(_ screen: NSScreen = NSScreen.preferred()) {
func buildUiAndShowPanel(_ screen: NSScreen = NSScreen.preferred()) {
guard appIsBeingUsed else { return }
Appearance.update()
guard appIsBeingUsed else { return }
thumbnailsPanel.makeKeyAndOrderFront(nil) // workaround: without this, switching between 2 screens make thumbnailPanel invisible
KeyRepeatTimer.toggleRepeatingKeyNextWindow()
guard appIsBeingUsed else { return }
refreshOpenUi()
refreshOpenUi([], skipUpdatesBeforeShowing: true) // first display with the data we have
guard appIsBeingUsed else { return }
thumbnailsPanel.show()
Windows.previewFocusedWindowIfNeeded()
guard appIsBeingUsed else { return }
Windows.refreshThumbnailsAsync(screen, 0)
guard appIsBeingUsed else { return }
Applications.refreshBadges()
KeyRepeatTimer.toggleRepeatingKeyNextWindow()
refreshOpenUi(Windows.list, skipUpdatesBeforeShowing: true, onlyUpdateScreenshots: true) // second refresh with fresh thumbnails
}
func checkIfShortcutsShouldBeDisabled(_ activeWindow: Window?, _ activeApp: NSRunningApplication?) {
@@ -314,10 +314,10 @@ class TableGroupView: ClickHoverStackView {
tableStackView.orientation = .vertical
tableStackView.spacing = 0
tableStackView.wantsLayer = true
tableStackView.layer?.backgroundColor = NSColor.tableBackgroundColor.cgColor
tableStackView.layer?.cornerRadius = TableGroupView.cornerRadius
tableStackView.layer?.borderColor = NSColor.tableBorderColor.cgColor
tableStackView.layer?.borderWidth = TableGroupView.borderWidth
tableStackView.layer!.backgroundColor = NSColor.tableBackgroundColor.cgColor
tableStackView.layer!.cornerRadius = TableGroupView.cornerRadius
tableStackView.layer!.borderColor = NSColor.tableBorderColor.cgColor
tableStackView.layer!.borderWidth = TableGroupView.borderWidth
addArrangedSubview(tableStackView)
tableStackViews.append(tableStackView)
rowInfoTables.append([RowInfo]())
+30 -14
View File
@@ -17,22 +17,43 @@ enum Symbols: String {
// Font icon using SF Symbols from the SF Pro font from Apple
// see https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/
class ThumbnailFontIconView: ThumbnailTitleView {
static var paragraphStyle = {
let paragraphStyle = NSMutableParagraphStyle()
// clip the top of the box since we know these symbols are always disks
paragraphStyle.lineHeightMultiple = 0.85
return paragraphStyle
}()
var initialAttributedString: NSMutableAttributedString!
convenience init(symbol: Symbols, tooltip: String? = nil, size: CGFloat = Appearance.fontHeight,
color: NSColor = Appearance.fontColor,
shadow: NSShadow? = ThumbnailView.makeShadow(Appearance.indicatedIconShadowColor)) {
// This helps SF symbols display vertically centered and not clipped at the top
self.init(shadow: shadow, font: NSFont(name: "SF Pro Text", size: (size * 0.85).rounded())!)
stringValue = symbol.rawValue
initialAttributedString = NSMutableAttributedString(string: symbol.rawValue, attributes: [.paragraphStyle: ThumbnailFontIconView.paragraphStyle])
attributedStringValue = initialAttributedString
textColor = color
addOrUpdateConstraint(widthAnchor, cell!.cellSize.width)
toolTip = tooltip
fixHeight()
addOrUpdateConstraint(widthAnchor, cell!.cellSize.width)
}
// number should be in the interval [0-50]
func setNumber(_ number: Int, _ filled: Bool) {
let (baseCharacter, offset) = baseCharacterAndOffset(number, filled)
assignIfDifferent(&stringValue, String(UnicodeScalar(Int(baseCharacter.unicodeScalars.first!.value) + offset)!))
replaceCharIfNeeded(String(UnicodeScalar(Int(baseCharacter.unicodeScalars.first!.value) + offset)!))
}
func setStar() {
replaceCharIfNeeded(Symbols.circledStar.rawValue)
}
func setFilledStar() {
replaceCharIfNeeded(Symbols.filledCircledStar.rawValue)
}
private func replaceCharIfNeeded(_ newChar: String) {
if newChar != attributedStringValue.string {
initialAttributedString.replaceCharacters(in: NSRange(location: 0, length: 1), with: newChar)
attributedStringValue = initialAttributedString
}
}
private func baseCharacterAndOffset(_ number: Int, _ filled: Bool) -> (String, Int) {
@@ -43,14 +64,6 @@ class ThumbnailFontIconView: ThumbnailTitleView {
return ((filled ? Symbols.filledCircledNumber10 : Symbols.circledNumber10).rawValue, number - 10)
}
}
func setStar() {
assignIfDifferent(&stringValue, Symbols.circledStar.rawValue)
}
func setFilledStar() {
assignIfDifferent(&stringValue, Symbols.filledCircledStar.rawValue)
}
}
class ThumbnailFilledFontIconView: NSView {
@@ -60,7 +73,10 @@ class ThumbnailFilledFontIconView: NSView {
let backgroundView = ThumbnailFontIconView(symbol: .filledCircled, size: size - 4, color: backgroundColor, shadow: nil)
addSubview(backgroundView)
addSubview(thumbnailFontIconView, positioned: .above, relativeTo: nil)
backgroundView.frame.origin = CGPoint(x: backgroundView.frame.origin.x + 2, y: backgroundView.frame.origin.y + 2)
fit(thumbnailFontIconView.fittingSize.width, thumbnailFontIconView.fittingSize.height)
backgroundView.centerXAnchor.constraint(equalTo: thumbnailFontIconView.centerXAnchor).isActive = true
let offset = ((thumbnailFontIconView.cell!.cellSize.width - backgroundView.cell!.cellSize.width) / 2).rounded()
backgroundView.topAnchor.constraint(equalTo: thumbnailFontIconView.topAnchor, constant: offset).isActive = true
widthAnchor.constraint(equalTo: thumbnailFontIconView.widthAnchor).isActive = true
heightAnchor.constraint(equalTo: thumbnailFontIconView.heightAnchor).isActive = true
}
}
+7 -1
View File
@@ -6,7 +6,6 @@ class ThumbnailTitleView: NSTextField {
self.font = font
textColor = Appearance.fontColor
self.shadow = shadow
lineBreakMode = getTruncationMode()
allowsDefaultTighteningForTruncation = false
translatesAutoresizingMaskIntoConstraints = false
}
@@ -19,6 +18,13 @@ class ThumbnailTitleView: NSTextField {
// no-op here prevents tooltips from disappearing on mouseMoved
}
func updateTruncationModeIfNeeded() {
let newLineBreakMode = getTruncationMode()
if lineBreakMode != newLineBreakMode {
lineBreakMode = newLineBreakMode
}
}
private func getTruncationMode() -> NSLineBreakMode {
if Preferences.titleTruncation == .end {
return .byTruncatingTail
+308 -310
View File
@@ -1,27 +1,28 @@
import Cocoa
class ThumbnailView: NSStackView {
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 thumbnailContainer: NSStackView!
let thumbnailContainer = FlippedView()
var appIcon = NSImageView()
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: ""))
var hiddenIcon = ThumbnailFontIconView(symbol: .circledSlashSign, tooltip: NSLocalizedString("App is hidden", comment: ""))
var spaceIcon = ThumbnailFontIconView(symbol: .circledNumber0)
var dockLabelIcon = ThumbnailFilledFontIconView(ThumbnailFontIconView(symbol: .filledCircledNumber0, size: dockLabelLabelSize(),
color: NSColor(srgbRed: 1, green: 0.30, blue: 0.25, alpha: 1), shadow: nil), backgroundColor: NSColor.white, size: dockLabelLabelSize())
var dockLabelIcon = ThumbnailFilledFontIconView(
ThumbnailFontIconView(symbol: .filledCircledNumber0, size: dockLabelLabelSize(), color: NSColor(srgbRed: 1, green: 0.30, blue: 0.25, alpha: 1), shadow: nil),
backgroundColor: NSColor.white, size: dockLabelLabelSize())
var quitIcon = TrafficLightButton(.quit, NSLocalizedString("Quit app", comment: ""))
var closeIcon = TrafficLightButton(.close, NSLocalizedString("Close window", comment: ""))
var minimizeIcon = TrafficLightButton(.miniaturize, NSLocalizedString("Minimize/Deminimize window", comment: ""))
var maximizeIcon = TrafficLightButton(.fullscreen, NSLocalizedString("Fullscreen/Defullscreen window", comment: ""))
var windowlessAppIndicator = WindowlessAppIndicator(tooltip: ThumbnailView.noOpenWindowToolTip)
var hStackView: NSStackView!
var vStackView: NSStackView!
let hStackView = FlippedView()
let vStackView = FlippedView()
var mouseUpCallback: (() -> Void)!
var mouseMovedCallback: (() -> Void)!
var dragAndDropTimer: Timer?
@@ -33,7 +34,8 @@ class ThumbnailView: NSStackView {
var indexInRow = 0
var numberOfViewsInRow = 0
var windowControlIcons: [TrafficLightButton]!
var windowControlIcons: [TrafficLightButton] { [quitIcon, closeIcon, minimizeIcon, maximizeIcon] }
var windowIndicatorIcons: [ThumbnailFontIconView] { [hiddenIcon, fullscreenIcon, minimizedIcon, spaceIcon] }
// for VoiceOver cursor
override var canBecomeKeyView: Bool { true }
@@ -41,149 +43,64 @@ class ThumbnailView: NSStackView {
override func isAccessibilityElement() -> Bool { true }
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
mouseMovedCallback()
return .link
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
dragAndDropTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { _ in
self.mouseUpCallback()
})
dragAndDropTimer?.tolerance = 0.2
return .link
}
override func draggingExited(_ sender: NSDraggingInfo?) {
dragAndDropTimer?.invalidate()
dragAndDropTimer = nil
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
dragAndDropTimer?.invalidate()
dragAndDropTimer = nil
let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as! [URL]
let appUrl = window_!.application.runningApplication.bundleURL!
let open = try? NSWorkspace.shared.open(urls, withApplicationAt: appUrl, options: [], configuration: [:])
App.app.hideUi()
return open != nil
}
override func mouseUp(with event: NSEvent) {
if event.clickCount >= 1 {
mouseUpCallback()
}
}
override func otherMouseUp(with event: NSEvent) {
// middle-click
if event.buttonNumber == 2 {
window_?.close()
}
}
func mouseMoved() {
showOrHideWindowControls(true)
mouseMovedCallback()
}
convenience init() {
self.init(frame: .zero)
setupView()
observeDragAndDrop()
}
private func setupView() {
orientation = .vertical
let shadow = ThumbnailView.makeShadow(Appearance.imageShadowColor)
thumbnail.shadow = shadow
windowlessIcon.toolTip = ThumbnailView.noOpenWindowToolTip
windowlessIcon.shadow = shadow
appIcon.shadow = shadow
label.fixHeight()
windowControlIcons = [quitIcon, closeIcon, minimizeIcon, maximizeIcon]
addViews()
addDockLabelIcon()
addWindowlessIndicator()
setAccessibilityChildren([])
}
private func addViews() {
vStackView = NSStackView()
vStackView.translatesAutoresizingMaskIntoConstraints = false
vStackView.orientation = .vertical
vStackView.wantsLayer = true
vStackView.layer!.backgroundColor = .clear
vStackView.layer!.borderColor = .clear
vStackView.layer!.cornerRadius = Appearance.cellCornerRadius
vStackView.layer!.borderWidth = CGFloat(1)
vStackView.edgeInsets = NSEdgeInsets(top: Appearance.edgeInsetsSize, left: Appearance.edgeInsetsSize,
bottom: Appearance.edgeInsetsSize, right: Appearance.edgeInsetsSize)
setViews([vStackView], in: .leading)
if Preferences.appearanceStyle == .appIcons {
// The label is outside and below the selected icon in AppIcons style
hStackView = NSStackView(views: [appIcon])
hStackView.translatesAutoresizingMaskIntoConstraints = false
vStackView.setViews([hStackView], in: .leading)
label.alignment = .center
label.isHidden = true
addSubview(label)
NSLayoutConstraint.activate([
hStackView.heightAnchor.constraint(equalTo: appIcon.heightAnchor),
vStackView.heightAnchor.constraint(equalTo: hStackView.heightAnchor, constant: Appearance.intraCellPadding),
label.topAnchor.constraint(equalTo: vStackView.bottomAnchor, constant: 8), // subjectively looks vertically centered
])
} else {
hStackView = NSStackView(views: [appIcon, label, hiddenIcon, fullscreenIcon, minimizedIcon, spaceIcon])
hStackView.translatesAutoresizingMaskIntoConstraints = false
hStackView.distribution = .fillProportionally
thumbnailContainer = NSStackView(views: [thumbnail, windowlessIcon])
thumbnailContainer.orientation = .vertical
vStackView.setViews([hStackView, thumbnailContainer], in: .leading)
NSLayoutConstraint.activate([
vStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
label.centerYAnchor.constraint(equalTo: hStackView.centerYAnchor),
thumbnailContainer.leftAnchor.constraint(equalTo: leftAnchor),
thumbnailContainer.rightAnchor.constraint(equalTo: rightAnchor),
])
if Preferences.appearanceStyle == .thumbnails {
windowControlIcons.forEach {
thumbnailContainer.addSubview($0, positioned: .above, relativeTo: nil)
$0.isHidden = true
}
}
}
NSLayoutConstraint.activate([
vStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
vStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
vStackView.topAnchor.constraint(equalTo: topAnchor),
])
}
private func addDockLabelIcon() {
appIcon.addSubview(dockLabelIcon, positioned: .above, relativeTo: nil)
}
private func addWindowlessIndicator() {
addSubview(windowlessAppIndicator, positioned: .above, relativeTo: nil)
}
func showOrHideWindowControls(_ shouldShowWindowControls: Bool) {
let shouldShow = shouldShowWindowControls && !Preferences.hideColoredCircles && !Appearance.hideThumbnails
if Preferences.appearanceStyle == .thumbnails, isShowingWindowControls != shouldShow {
isShowingWindowControls = shouldShow
var xOffset = 3 + Appearance.edgeInsetsSize
var yOffset = 2 + TrafficLightButton.size
windowControlIcons.forEach { icon in
icon.isHidden = !shouldShow ||
(icon.type == .quit && !(window_?.application.canBeQuit() ?? true)) ||
(icon.type == .close && !(window_?.canBeClosed() ?? true)) ||
((icon.type == .miniaturize || icon.type == .fullscreen) && !(window_?.canBeMinDeminOrFullscreened() ?? true))
if !icon.isHidden {
icon.setFrameOrigin(NSPoint(
x: xOffset,
y: thumbnailContainer.frame.height - yOffset))
xOffset += TrafficLightButton.size + TrafficLightButton.spacing
if xOffset + TrafficLightButton.size > thumbnailContainer.frame.width {
xOffset = 3 + Appearance.edgeInsetsSize
yOffset += TrafficLightButton.size + TrafficLightButton.spacing
}
}
// Force the icons to redraw, or after clicking the fullscreen button,
// it will still appear to be in fullscreen mode.
icon.display()
}
}
}
private func getBackgroundColor(isFocused: Bool, isHovered: Bool) -> NSColor {
if isFocused {
return Appearance.highlightFocusedBackgroundColor
}
if isHovered {
return Appearance.highlightHoveredBackgroundColor
}
return NSColor.clear
}
private func setBackground(isFocused: Bool, isHovered: Bool) {
vStackView?.layer!.backgroundColor = getBackgroundColor(isFocused: isFocused, isHovered: isHovered).cgColor
}
private func setBorder(isFocused: Bool, isHovered: Bool) {
if isFocused {
vStackView?.layer!.borderColor = Appearance.highlightFocusedBorderColor.cgColor
vStackView?.layer!.borderWidth = Appearance.highlightBorderWidth
} else if isHovered {
vStackView?.layer!.borderColor = Appearance.highlightHoveredBorderColor.cgColor
vStackView?.layer!.borderWidth = Appearance.highlightBorderWidth
} else {
vStackView?.layer!.borderColor = NSColor.clear.cgColor
vStackView?.layer!.borderWidth = 0
}
}
private func setShadow(isFocused: Bool, isHovered: Bool) {
if (isFocused || isHovered) && Appearance.highlightBorderShadowColor != .clear {
vStackView?.layer!.shadowColor = Appearance.highlightBorderShadowColor.cgColor
vStackView?.layer!.shadowOpacity = 0.25
vStackView?.layer!.shadowOffset = .zero
vStackView?.layer!.shadowRadius = 1
}
func updateRecycledCellWithNewContent(_ element: Window, _ index: Int, _ newHeight: CGFloat, _ screen: NSScreen) {
window_ = element
updateValues(element, index, newHeight, screen)
updateSizes(newHeight, screen)
updatePositions(newHeight, screen)
label.toolTip = label.cell!.cellSize.width >= label.frame.size.width ? label.stringValue : nil
}
func drawHighlight() {
@@ -198,13 +115,131 @@ class ThumbnailView: NSStackView {
}
}
func showOrHideWindowControls(_ shouldShowWindowControls: Bool) {
let shouldShow = shouldShowWindowControls && !Preferences.hideColoredCircles && !Appearance.hideThumbnails
if Preferences.appearanceStyle == .thumbnails, isShowingWindowControls != shouldShow {
isShowingWindowControls = shouldShow
for icon in windowControlIcons {
icon.isHidden = !shouldShow
|| (icon.type == .quit && !(window_?.application.canBeQuit() ?? true))
|| (icon.type == .close && !(window_?.canBeClosed() ?? true))
|| ((icon.type == .miniaturize || icon.type == .fullscreen) && !(window_?.canBeMinDeminOrFullscreened() ?? true))
// Force the icons to redraw; otherwise after clicking the fullscreen button,
// it will still appear to be in fullscreen mode.
icon.display()
}
}
}
@discardableResult
func updateDockLabelIcon(_ dockLabel: String?) -> Bool {
assignIfDifferent(&dockLabelIcon.isHidden, dockLabel == nil || Preferences.hideAppBadges || Appearance.iconSize == 0)
if !dockLabelIcon.isHidden, let dockLabel = dockLabel {
let view = dockLabelIcon.subviews[1] as! ThumbnailFontIconView
let dockLabelInt = Int(dockLabel)
if dockLabelInt == nil || dockLabelInt! > 30 {
view.setFilledStar()
} else {
view.setNumber(dockLabelInt!, true)
}
view.setAccessibilityLabel(getAccessibilityTextForBadge(dockLabel))
return true
}
return false
}
private func setupView() {
translatesAutoresizingMaskIntoConstraints = false
setAccessibilityChildren([])
wantsLayer = true
layer!.masksToBounds = false // without this, label will be clipped in app-icons style since its larger than its parentView
setupSharedSubiews()
setupStyleSpecificSubviews()
}
private func setupSharedSubiews() {
let shadow = ThumbnailView.makeShadow(Appearance.imageShadowColor)
thumbnail.shadow = shadow
windowlessIcon.translatesAutoresizingMaskIntoConstraints = false
windowlessIcon.toolTip = ThumbnailView.noOpenWindowToolTip
windowlessIcon.shadow = shadow
appIcon.translatesAutoresizingMaskIntoConstraints = false
appIcon.shadow = shadow
appIcon.setSubviewAbove(dockLabelIcon)
label.fixHeight()
vStackView.wantsLayer = true
vStackView.layer!.backgroundColor = .clear
vStackView.layer!.borderColor = .clear
vStackView.layer!.cornerRadius = Appearance.cellCornerRadius
vStackView.layer!.borderWidth = CGFloat(1)
setSubviews([vStackView])
vStackView.setSubviews([hStackView])
hStackView.setSubviews([appIcon])
}
private func setupStyleSpecificSubviews() {
if Preferences.appearanceStyle == .appIcons {
addSubviews([label])
hStackView.setSubviewAbove(windowlessAppIndicator)
label.alignment = .center
label.isHidden = true
} else if Preferences.appearanceStyle == .thumbnails {
vStackView.addSubviews([thumbnailContainer])
thumbnailContainer.setSubviews([thumbnail, windowlessIcon])
thumbnailContainer.setSubviewAbove(windowlessAppIndicator)
for icon in windowControlIcons {
thumbnailContainer.setSubviewAbove(icon)
icon.isHidden = true
}
hStackView.addSubviews([label] + windowIndicatorIcons)
} else {
hStackView.setSubviewAbove(windowlessAppIndicator)
hStackView.addSubviews([label] + windowIndicatorIcons)
}
}
private func getBackgroundColor(isFocused: Bool, isHovered: Bool) -> NSColor {
if isFocused {
return Appearance.highlightFocusedBackgroundColor
}
if isHovered {
return Appearance.highlightHoveredBackgroundColor
}
return NSColor.clear
}
private func setBackground(isFocused: Bool, isHovered: Bool) {
vStackView.layer!.backgroundColor = getBackgroundColor(isFocused: isFocused, isHovered: isHovered).cgColor
}
private func setBorder(isFocused: Bool, isHovered: Bool) {
if isFocused {
vStackView.layer!.borderColor = Appearance.highlightFocusedBorderColor.cgColor
vStackView.layer!.borderWidth = Appearance.highlightBorderWidth
} else if isHovered {
vStackView.layer!.borderColor = Appearance.highlightHoveredBorderColor.cgColor
vStackView.layer!.borderWidth = Appearance.highlightBorderWidth
} else {
vStackView.layer!.borderColor = NSColor.clear.cgColor
vStackView.layer!.borderWidth = 0
}
}
private func setShadow(isFocused: Bool, isHovered: Bool) {
if (isFocused || isHovered) && Appearance.highlightBorderShadowColor != .clear {
vStackView.layer!.shadowColor = Appearance.highlightBorderShadowColor.cgColor
vStackView.layer!.shadowOpacity = 0.25
vStackView.layer!.shadowOffset = .zero
vStackView.layer!.shadowRadius = 1
}
}
private func updateAppIconsLabel(isFocused: Bool, isHovered: Bool) {
let focusedView = ThumbnailsView.recycledViews[Windows.focusedWindowIndex]
var hoveredView: ThumbnailView? = nil
if Windows.hoveredWindowIndex != nil {
hoveredView = ThumbnailsView.recycledViews[Windows.hoveredWindowIndex!]
}
if isFocused || (!isFocused && !isHovered) {
hoveredView?.label.isHidden = true
focusedView.label.isHidden = false
@@ -218,7 +253,7 @@ class ThumbnailView: NSStackView {
}
}
func getMaxAllowedLabelWidth(_ view: ThumbnailView) -> CGFloat {
private func getMaxAllowedLabelWidth(_ view: ThumbnailView) -> CGFloat {
let viewWidth = view.frame.width
let maxAllowedWidth = min(viewWidth * 2, ThumbnailsView.thumbnailsWidth)
@@ -256,50 +291,17 @@ class ThumbnailView: NSStackView {
}
}
let xPosition = -leftOffset
let yPosition = Appearance.intraCellPadding
let height = view.label.cell!.cellSize.height
let yPosition = view.hStackView.frame.origin.y + view.hStackView.frame.height + Appearance.intraCellPadding * 2
view.label.frame = NSRect(x: xPosition, y: yPosition, width: effectiveLabelWidth, height: height)
view.label.addOrUpdateConstraint(view.label.widthAnchor, effectiveLabelWidth)
view.label.toolTip = view.label.cell!.cellSize.width >= view.label.frame.size.width ? view.label.stringValue : nil
}
func updateRecycledCellWithNewContent(_ element: Window, _ index: Int, _ newHeight: CGFloat, _ screen: NSScreen) {
window_ = element
private func updateValues(_ element: Window, _ index: Int, _ newHeight: CGFloat, _ screen: NSScreen) {
assignIfDifferent(&thumbnail.isHidden, Appearance.hideThumbnails || element.isWindowlessApp)
if !thumbnail.isHidden {
thumbnail.image = element.thumbnail
if let image = thumbnail.image {
image.size = element.thumbnailFullSize!
} else {
thumbnail.image = element.icon?.copy() as? NSImage
thumbnail.image?.size = NSSize(width: 1024, height: 1024)
}
let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail.image, screen, false)
thumbnail.image?.size = thumbnailSize
thumbnail.frame.size = thumbnailSize
// for Accessibility > "speak items under the pointer"
thumbnail.setAccessibilityLabel(element.title)
}
assignIfDifferent(&vStackView.spacing, Appearance.hideThumbnails ? 0 : Appearance.intraCellPadding)
assignIfDifferent(&spacing, Appearance.intraCellPadding)
assignIfDifferent(&hStackView.spacing, Appearance.fontHeight == 0 ? 0 : Appearance.intraCellPadding)
let title = getAppOrAndWindowTitle()
let appIconChanged = appIcon.image != element.icon
if appIconChanged {
appIcon.image = element.icon
let appIconSize = ThumbnailView.iconSize(screen)
appIcon.image?.size = appIconSize
appIcon.addOrUpdateConstraint(appIcon.widthAnchor, appIconSize.width)
appIcon.addOrUpdateConstraint(appIcon.heightAnchor, appIconSize.height)
appIcon.setAccessibilityLabel(title)
}
let labelChanged = label.stringValue != title
if labelChanged {
label.stringValue = title
// workaround: setting string on NSTextView changes the font (most likely a Cocoa bug)
label.font = Appearance.font
setAccessibilityLabel(title)
}
assignIfDifferent(&windowlessIcon.isHidden, !element.isWindowlessApp || Appearance.hideThumbnails)
assignIfDifferent(&windowlessAppIndicator.isHidden, !element.isWindowlessApp)
assignIfDifferent(&hiddenIcon.isHidden, !element.isHidden || Preferences.hideStatusIcons)
assignIfDifferent(&fullscreenIcon.isHidden, !element.isFullscreen || Preferences.hideStatusIcons)
assignIfDifferent(&minimizedIcon.isHidden, !element.isMinimized || Preferences.hideStatusIcons)
@@ -308,6 +310,18 @@ class ThumbnailView: NSStackView {
NSScreen.screens.count < 2 || Preferences.screensToShow[App.app.shortcutIndex] == .showingAltTab
)
))
if !thumbnail.isHidden {
thumbnail.image = element.thumbnail
// for Accessibility > "speak items under the pointer"
thumbnail.setAccessibilityLabel(element.title)
}
let title = getAppOrAndWindowTitle()
let labelChanged = label.stringValue != title
if labelChanged {
label.stringValue = title
setAccessibilityLabel(title)
}
label.updateTruncationModeIfNeeded()
if !spaceIcon.isHidden {
if element.spaceIndex > 30 || element.isOnAllSpaces {
spaceIcon.setStar()
@@ -317,39 +331,120 @@ class ThumbnailView: NSStackView {
spaceIcon.toolTip = String(format: NSLocalizedString("Window is on Space %d", comment: ""), element.spaceIndex)
}
}
let appIconChanged = appIcon.image != element.icon
if appIconChanged {
appIcon.image = element.icon
appIcon.setAccessibilityLabel(title)
}
let dockLabelChanged = updateDockLabelIcon(element.dockLabel)
if appIconChanged || dockLabelChanged {
setAccessibilityHelp(getAccessibilityHelp(element.application.runningApplication.localizedName, element.dockLabel))
}
label.toolTip = label.cell!.cellSize.width >= label.frame.size.width ? label.stringValue : nil
assignIfDifferent(&windowlessIcon.isHidden, !element.isWindowlessApp || Appearance.hideThumbnails)
if element.isWindowlessApp {
windowlessIcon.image = appIcon.image!.copy() as? NSImage
windowlessIcon.image?.size = NSSize(width: 1024, height: 1024)
let windowlessIconSize = ThumbnailView.thumbnailSize(windowlessIcon.image, screen, true)
windowlessIcon.image!.size = windowlessIconSize
windowlessIcon.frame.size = windowlessIconSize
windowlessIcon.needsDisplay = true
}
setFrameWidthHeight(element, screen, newHeight)
let hStackViewWidth = frame.width - Appearance.edgeInsetsSize * 2
hStackView.addOrUpdateConstraint(hStackView.widthAnchor, hStackViewWidth)
updateWindowlessIndicator(element, screen)
self.mouseUpCallback = { () -> Void in App.app.focusSelectedWindow(element) }
self.mouseMovedCallback = { () -> Void in Windows.updateFocusedAndHoveredWindowIndex(index, true) }
windowControlIcons.forEach { $0.window_ = element }
showOrHideWindowControls(false)
// force a display to avoid flickering; see https://github.com/lwouis/alt-tab-macos/issues/197
// quirk: display() should be called last as it resets thumbnail.frame.size somehow
if labelChanged {
label.display()
mouseUpCallback = { () -> Void in App.app.focusSelectedWindow(element) }
mouseMovedCallback = { () -> Void in Windows.updateFocusedAndHoveredWindowIndex(index, true) }
}
private func updateSizes(_ newHeight: CGFloat, _ screen: NSScreen) {
if Preferences.appearanceStyle == .thumbnails {
if !thumbnail.isHidden {
let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail.image, screen, false)
thumbnail.image?.size = thumbnailSize
thumbnail.frame.size = thumbnailSize
}
if !windowlessIcon.isHidden {
let windowlessIconSize = ThumbnailView.thumbnailSize(windowlessIcon.image, screen, true)
windowlessIcon.image!.size = windowlessIconSize
windowlessIcon.frame.size = windowlessIconSize
}
}
setFrameWidthHeight(screen, newHeight)
let appIconSize = ThumbnailView.iconSize(screen)
appIcon.image?.size = appIconSize
appIcon.frame.size = 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)
} else {
vStackView.frame.size = NSSize(width: frame.width, height: frame.height)
hStackView.frame.size = NSSize(width: frame.width - Appearance.edgeInsetsSize * 2, height: max(appIcon.frame.height, label.cell!.cellSize.height))
let labelWidth = hStackView.frame.width - appIcon.frame.width - Appearance.intraCellPadding - indicatorsSpace()
label.frame.size.width = labelWidth
label.addOrUpdateConstraint(label.widthAnchor, labelWidth)
}
if Preferences.appearanceStyle == .thumbnails {
let content = !windowlessIcon.isHidden ? windowlessIcon : thumbnail
thumbnailContainer.frame.size = NSSize(width: hStackView.frame.width, height: content.frame.height)
}
}
func getAppOrAndWindowTitle() -> String {
private func updatePositions(_ newHeight: CGFloat, _ screen: NSScreen) {
hStackView.frame.origin = NSPoint(x: Appearance.edgeInsetsSize, y: Appearance.edgeInsetsSize)
if Preferences.appearanceStyle != .appIcons {
appIcon.frame.origin.x = App.shared.userInterfaceLayoutDirection == .leftToRight
? 0
: hStackView.frame.width - appIcon.frame.width
let iconWidth = windowIndicatorIcons.first!.cell!.cellSize.width
var indicatorSpace = CGFloat(0)
for icon in windowIndicatorIcons {
if !icon.isHidden {
indicatorSpace += iconWidth
icon.centerFrameInParent(y: true)
icon.frame.origin.x = App.shared.userInterfaceLayoutDirection == .leftToRight
? hStackView.frame.width - indicatorSpace
: indicatorSpace - iconWidth
}
}
let labelWidth = hStackView.frame.width - appIcon.frame.width - Appearance.intraCellPadding - indicatorSpace
label.frame.origin.x = App.shared.userInterfaceLayoutDirection == .leftToRight
? appIcon.frame.maxX + Appearance.intraCellPadding
: hStackView.frame.width - appIcon.frame.width - Appearance.intraCellPadding - labelWidth
label.centerFrameInParent(y: true)
}
if Preferences.appearanceStyle == .thumbnails {
let content = !windowlessIcon.isHidden ? windowlessIcon : thumbnail
thumbnailContainer.frame.origin = NSPoint(x: Appearance.edgeInsetsSize, y: hStackView.frame.maxY + Appearance.intraCellPadding)
content.centerFrameInParent(x: true)
var xOffset = CGFloat(3)
var yOffset = CGFloat(2)
for icon in windowControlIcons {
icon.frame.origin = NSPoint(x: xOffset, y: yOffset)
xOffset += TrafficLightButton.size + TrafficLightButton.spacing
if xOffset + TrafficLightButton.size > thumbnailContainer.frame.width {
xOffset = 3 + Appearance.edgeInsetsSize
yOffset += TrafficLightButton.size + TrafficLightButton.spacing
}
}
}
if !windowlessAppIndicator.isHidden {
if Preferences.appearanceStyle != .titles {
windowlessAppIndicator.centerFrameInParent(x: true)
} else {
windowlessAppIndicator.frame.origin.x = ((appIcon.frame.width / 2) - (windowlessAppIndicator.frame.width / 2)).rounded()
+ (App.shared.userInterfaceLayoutDirection == .leftToRight ? 0 : appIcon.frame.origin.x)
}
windowlessAppIndicator.frame.origin.y = windowlessAppIndicator.superview!.frame.height - windowlessAppIndicator.frame.height
}
if !dockLabelIcon.isHidden {
let iconSize = ThumbnailView.iconSize(screen)
dockLabelIcon.frame.origin.x = ((iconSize.width * 0.8) - (dockLabelIcon.fittingSize.width / 2)).rounded()
dockLabelIcon.frame.origin.y = ((iconSize.height * 0.8) - (dockLabelIcon.fittingSize.height / 2)).rounded()
- (Preferences.appearanceSize == .small ? 2 : 0)
}
}
private func indicatorsSpace() -> CGFloat {
let iconWidth = windowIndicatorIcons.first!.cell!.cellSize.width
return CGFloat(windowIndicatorIcons.filter{ !$0.isHidden }.count) * iconWidth
}
private func getAppOrAndWindowTitle() -> String {
let appName = window_?.application.runningApplication.localizedName
let windowTitle = window_?.title
if Preferences.onlyShowApplications() || Preferences.showTitles == .appName {
return appName ?? ""
} else if Preferences.showTitles == .appNameAndWindowTitle {
@@ -358,84 +453,31 @@ class ThumbnailView: NSStackView {
return windowTitle ?? ""
}
func setFrameWidthHeight(_ element: Window, _ screen: NSScreen, _ newHeight: CGFloat) {
// Retrieves the minimum width for the screen.
let widthMin = ThumbnailView.minThumbnailWidth(screen)
let leftRightEdgeInsetsSize = ThumbnailView.getLeftRightEdgeInsetsSize()
var width = CGFloat(0)
private func setFrameWidthHeight(_ screen: NSScreen, _ newHeight: CGFloat) {
var contentWidth = CGFloat(0)
if Preferences.appearanceStyle == .thumbnails {
// Preferred to the width of the image, and the minimum width may be set to be large.
if element.isWindowlessApp {
contentWidth = windowlessIcon.frame.size.width
} else {
contentWidth = thumbnail.frame.size.width
}
contentWidth = (!windowlessIcon.isHidden ? windowlessIcon : thumbnail).frame.size.width
} else if Preferences.appearanceStyle == .titles {
contentWidth = ThumbnailView.maxThumbnailWidth(screen) - Appearance.edgeInsetsSize * 2
} else {
contentWidth = max(hStackView.frame.size.width, Appearance.iconSize)
contentWidth = Appearance.iconSize
}
let leftRightEdgeInsetsSize = ThumbnailView.getLeftRightEdgeInsetsSize()
let frameWidth = (contentWidth + leftRightEdgeInsetsSize).rounded()
width = max(frameWidth, widthMin).rounded()
let widthMin = ThumbnailView.minThumbnailWidth(screen)
let width = max(frameWidth, widthMin).rounded()
assignIfDifferent(&frame.size.width, width)
assignIfDifferent(&frame.size.height, newHeight)
}
func updateWindowlessIndicator(_ element: Window, _ screen: NSScreen) {
assignIfDifferent(&windowlessAppIndicator.isHidden, !element.isWindowlessApp)
if element.isWindowlessApp {
var xOffset = CGFloat(0)
var yOffset = CGFloat(0)
if Preferences.appearanceStyle == .thumbnails {
xOffset = (frame.size.width - windowlessAppIndicator.frame.size.width) / 2
yOffset = Appearance.edgeInsetsSize
} else if Preferences.appearanceStyle == .appIcons {
xOffset = (frame.size.width - windowlessAppIndicator.frame.size.width) / 2
yOffset = label.cell!.cellSize.height + 2 * Appearance.intraCellPadding + Appearance.edgeInsetsSize / 2
} else if Preferences.appearanceStyle == .titles {
let iconSize = ThumbnailView.iconSize(screen)
yOffset = Appearance.edgeInsetsSize / 2
if App.shared.userInterfaceLayoutDirection == .leftToRight {
xOffset = Appearance.edgeInsetsSize + (iconSize.width - windowlessAppIndicator.frame.size.width) / 2
} else {
xOffset = frame.size.width - Appearance.edgeInsetsSize - (iconSize.width / 2) - (windowlessAppIndicator.frame.size.width / 2)
}
}
assignIfDifferent(&windowlessAppIndicator.frame.origin.x, xOffset)
assignIfDifferent(&windowlessAppIndicator.frame.origin.y, yOffset)
}
}
@discardableResult
func updateDockLabelIcon(_ dockLabel: String?) -> Bool {
assignIfDifferent(&dockLabelIcon.isHidden, dockLabel == nil || Preferences.hideAppBadges || Appearance.iconSize == 0)
if !dockLabelIcon.isHidden, let dockLabel = dockLabel {
let view = dockLabelIcon.subviews[1] as! ThumbnailFontIconView
let dockLabelInt = Int(dockLabel)
if dockLabelInt == nil || dockLabelInt! > 30 {
view.setFilledStar()
} else {
view.setNumber(dockLabelInt!, true)
}
let badgeOffset = Preferences.appearanceSize == .small ? CGFloat(1) : CGFloat(0)
let iconSize = ThumbnailView.iconSize(NSScreen.preferred())
dockLabelIcon.setFrameOrigin(NSPoint(
x: iconSize.width - (dockLabelIcon.fittingSize.width / 2) - (iconSize.width / 7) - badgeOffset,
y: iconSize.height - (dockLabelIcon.fittingSize.height / 2) - (iconSize.height / 5) - badgeOffset))
view.setAccessibilityLabel(getAccessibilityTextForBadge(dockLabel))
return true
}
return false
}
func getAccessibilityHelp(_ appName: String?, _ dockLabel: String?) -> String {
private func getAccessibilityHelp(_ appName: String?, _ dockLabel: String?) -> String {
[appName, dockLabel.map { getAccessibilityTextForBadge($0) }]
.compactMap { $0 }
.joined(separator: " - ")
}
func getAccessibilityTextForBadge(_ dockLabel: String) -> String {
private func getAccessibilityTextForBadge(_ dockLabel: String) -> String {
if let dockLabelInt = Int(dockLabel) {
return "Red badge with number \(dockLabelInt)"
}
@@ -451,54 +493,10 @@ class ThumbnailView: NSStackView {
registerForDraggedTypes([NSPasteboard.PasteboardType(kUTTypeURL as String)])
}
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
mouseMovedCallback()
return .link
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
dragAndDropTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { _ in
self.mouseUpCallback()
})
dragAndDropTimer?.tolerance = 0.2
return .link
}
override func draggingExited(_ sender: NSDraggingInfo?) {
dragAndDropTimer?.invalidate()
dragAndDropTimer = nil
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
dragAndDropTimer?.invalidate()
dragAndDropTimer = nil
let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as! [URL]
let appUrl = window_!.application.runningApplication.bundleURL!
let open = try? NSWorkspace.shared.open(urls, withApplicationAt: appUrl, options: [], configuration: [:])
App.app.hideUi()
return open != nil
}
func mouseMoved() {
showOrHideWindowControls(true)
mouseMovedCallback()
}
override func mouseUp(with event: NSEvent) {
if event.clickCount >= 1 {
mouseUpCallback()
}
}
override func otherMouseUp(with event: NSEvent) {
// middle-click
if event.buttonNumber == 2 {
window_?.close()
}
}
static func dockLabelLabelSize() -> CGFloat {
return (ThumbnailView.iconSize(NSScreen.preferred()).width * 0.43).rounded()
let size = (ThumbnailView.iconSize(NSScreen.preferred()).width * 0.43).rounded()
// label should have a minimum size for readability
return max(size, 13)
}
static func makeShadow(_ color: NSColor?) -> NSShadow? {
@@ -37,6 +37,7 @@ class ClickHoverImageView: MouseHoverView {
init(infoCircle: NSView) {
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
self.infoCircle = infoCircle
addSubview(infoCircle)
@@ -130,7 +131,7 @@ class LabelAndControl: NSObject {
return checkbox
}
static func makeInfoButton(size: CGFloat = 18,
static func makeInfoButton(size: CGFloat = 16,
onClick: EventClosure? = nil,
onMouseEntered: EventClosure? = nil,
onMouseExited: EventClosure? = nil) -> ClickHoverImageView {
@@ -320,7 +321,7 @@ class LabelAndControl: NSObject {
}
// some preferences require re-creating some components
if (!(senderControl is NSSlider) || (NSEvent.pressedMouseButtons & (1 << 0)) == 0) &&
(["appearanceStyle", "appearanceSize", "appearanceTheme", "appearanceVisibility", "titleTruncation", "showOnScreen", "showAppsOrWindows"].contains { (pref: String) -> Bool in
(["appearanceStyle", "appearanceSize", "appearanceTheme", "appearanceVisibility", "showOnScreen", "showAppsOrWindows"].contains { (pref: String) -> Bool in
pref == senderControl.identifier!.rawValue
}) {
(App.shared as! App).resetPreferencesDependentComponents()
@@ -27,8 +27,8 @@ class IllustratedImageThemeView: ClickHoverImageView {
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.imageScaling = .scaleProportionallyUpOrDown
imageView.wantsLayer = true
imageView.layer?.masksToBounds = true
imageView.layer?.cornerRadius = TableGroupView.cornerRadius
imageView.layer!.masksToBounds = true
imageView.layer!.cornerRadius = TableGroupView.cornerRadius
super.init(infoCircle: imageView)
self.style = style