feat: switcher displays faster; other performance improvements
This commit is contained in:
1 parent
7207ad6da7
commit
b51ff6583d
20 files changed
+563
-493
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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]())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user