diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 0f72a46a..2d865d65 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -18073,6 +18073,60 @@ } } }, + "Move to Desktop 1" : { + "comment" : "Window action" + }, + "Move to Desktop 2" : { + "comment" : "Window action" + }, + "Move to Desktop 3" : { + "comment" : "Window action" + }, + "Move to Desktop 4" : { + "comment" : "Window action" + }, + "Move to Desktop 5" : { + "comment" : "Window action" + }, + "Move to Desktop 6" : { + "comment" : "Window action" + }, + "Move to Desktop 7" : { + "comment" : "Window action" + }, + "Move to Desktop 8" : { + "comment" : "Window action" + }, + "Move to Desktop 9" : { + "comment" : "Window action" + }, + "Move to Desktop 10" : { + "comment" : "Window action" + }, + "Move to Desktop 11" : { + "comment" : "Window action" + }, + "Move to Desktop 12" : { + "comment" : "Window action" + }, + "Move to Desktop 13" : { + "comment" : "Window action" + }, + "Move to Desktop 14" : { + "comment" : "Window action" + }, + "Move to Desktop 15" : { + "comment" : "Window action" + }, + "Move to Desktop 16" : { + "comment" : "Window action" + }, + "Move to Next Space" : { + "comment" : "Window action" + }, + "Move to Previous Space" : { + "comment" : "Window action" + }, "Move Up" : { "comment" : "Window action", "localizations" : { @@ -29998,6 +30052,9 @@ } } }, + "Space Switching" : { + "comment" : "Section header in the action picker of the Keybinds tab" + }, "Stage Manager" : { "comment" : "Section header shown in settings", "localizations" : { diff --git a/Loop/Private APIs/SkyLightBridgedSPI.swift b/Loop/Private APIs/SkyLightBridgedSPI.swift new file mode 100644 index 00000000..f5a1d467 --- /dev/null +++ b/Loop/Private APIs/SkyLightBridgedSPI.swift @@ -0,0 +1,136 @@ +// +// SkyLightBridgedSPI.swift +// Loop +// +// Created by Kai Azim on 2026-07-02. +// Thanks to Stephan Casas for originally showing me these private space-switching APIs :) +// + +import CoreGraphics +import Darwin +import Foundation + +@available(macOS 14.0, *) +enum SkyLightBridgedSPI { + private static let objcMessageSend: UnsafeMutableRawPointer? = { + guard let handle = dlopen(nil, RTLD_LAZY) else { + return nil + } + + return dlsym(handle, "objc_msgSend") + }() + + static func moveWindow(_ windowID: CGWindowID, toSpace spaceID: UInt64) -> Bool { + guard let operation = makeMoveWindowsToManagedSpaceOperation(windowIDs: [windowID], spaceID: spaceID) else { + return false + } + + _ = performWithWMBridgeDelegate(operation) + return true + } + + static func copyManagedDisplaySpaces() -> [NSDictionary]? { + guard let operationClass = NSClassFromString("SLSBridgedCopyManagedDisplaySpacesOperation") as? NSObject.Type else { + return nil + } + + let operation = operationClass.init() + guard let result = performWithWMBridgeDelegate(operation), + let propertyList = result.value(forKey: "propertyListArray") as? [NSDictionary] + else { + return nil + } + + return propertyList + } + + static func copySpaces(forWindows windowIDs: [CGWindowID], options: SLSSpaceMask) -> [NSNumber]? { + guard let operation = makeCopySpacesForWindowsOperation(windowIDs: windowIDs, options: options), + let result = performWithWMBridgeDelegate(operation), + let numbers = result.value(forKey: "numbers") as? [NSNumber] + else { + return nil + } + + return numbers + } + + private static func performWithWMBridgeDelegate(_ operation: AnyObject) -> AnyObject? { + guard let objcMessageSend else { + return nil + } + + let selector = NSSelectorFromString("performWithWMBridgeDelegate") + guard operation.responds(to: selector) else { + return nil + } + + typealias PerformWithWMBridgeDelegate = @convention(c) (AnyObject, Selector) -> AnyObject? + let performWithWMBridgeDelegate = unsafeBitCast(objcMessageSend, to: PerformWithWMBridgeDelegate.self) + return performWithWMBridgeDelegate(operation, selector) + } + + private static func allocate(_ operationClass: NSObject.Type) -> AnyObject? { + guard let objcMessageSend else { + return nil + } + + typealias AllocateOperation = @convention(c) (AnyClass, Selector) -> AnyObject? + let allocateOperation = unsafeBitCast(objcMessageSend, to: AllocateOperation.self) + return allocateOperation(operationClass, NSSelectorFromString("alloc")) + } + + private static func makeMoveWindowsToManagedSpaceOperation( + windowIDs: [CGWindowID], + spaceID: UInt64 + ) -> AnyObject? { + guard let objcMessageSend, + let operationClass = NSClassFromString("SLSBridgedMoveWindowsToManagedSpaceOperation") as? NSObject.Type + else { + return nil + } + + let initializer = NSSelectorFromString("initWithWindows:spaceID:") + guard operationClass.instancesRespond(to: initializer), + let allocatedOperation = allocate(operationClass) + else { + return nil + } + + typealias InitMoveWindowsToManagedSpace = @convention(c) (AnyObject, Selector, NSArray, UInt64) -> AnyObject? + let initMoveWindowsToManagedSpace = unsafeBitCast(objcMessageSend, to: InitMoveWindowsToManagedSpace.self) + return initMoveWindowsToManagedSpace( + allocatedOperation, + initializer, + windowIDs.map { NSNumber(value: $0) } as NSArray, + spaceID + ) + } + + private static func makeCopySpacesForWindowsOperation( + windowIDs: [CGWindowID], + options: SLSSpaceMask + ) -> AnyObject? { + guard let objcMessageSend, + let operationClass = NSClassFromString("SLSBridgedCopySpacesForWindowsOperation") as? NSObject.Type + else { + return nil + } + + let initializer = NSSelectorFromString("initWithOptions:windows:") + guard operationClass.instancesRespond(to: initializer), + let allocatedOperation = allocate(operationClass) + else { + return nil + } + + typealias InitCopySpacesForWindows = @convention(c) (AnyObject, Selector, Int32, NSArray) -> AnyObject? + let initCopySpacesForWindows = unsafeBitCast(objcMessageSend, to: InitCopySpacesForWindows.self) + return initCopySpacesForWindows( + allocatedOperation, + initializer, + options.rawValue, + windowIDs.map { NSNumber(value: $0) } as NSArray + ) + } +} diff --git a/Loop/Private APIs/SkyLightSymbolLoader.swift b/Loop/Private APIs/SkyLightSymbolLoader.swift index 67ac6f91..d0888b87 100644 --- a/Loop/Private APIs/SkyLightSymbolLoader.swift +++ b/Loop/Private APIs/SkyLightSymbolLoader.swift @@ -138,4 +138,10 @@ struct SLSWindowCaptureOptions: OptionSet { static let fullSize = Self(rawValue: 1 << 19) } +enum SLSSpaceMask: Int32 { + case onScreenManaged = 5 + case offScreenManaged = 6 + case allManaged = 7 +} + let kCPSUserGenerated: UInt32 = 0x200 diff --git a/Loop/Private APIs/SkyLightToolBelt.swift b/Loop/Private APIs/SkyLightToolBelt.swift index 907ed754..43def4e7 100644 --- a/Loop/Private APIs/SkyLightToolBelt.swift +++ b/Loop/Private APIs/SkyLightToolBelt.swift @@ -11,6 +11,53 @@ import SwiftUI /// A wrapper for functions defined in `SkyLightSymbolLoader` @Loggable(style: .static) enum SkyLightToolBelt { + struct ManagedDisplay { + let identifier: UUID + let spaces: [ManagedSpace] + let currentSpace: ManagedSpace + + init?(dictionary: NSDictionary) { + guard + let identifier = UUID(uuidString: dictionary["Display Identifier"] as? String ?? ""), + let spaces = (dictionary["Spaces"] as? [NSDictionary])?.compactMap({ ManagedSpace(dictionary: $0) }), + let currentSpace = ManagedSpace(dictionary: dictionary["Current Space"] as? NSDictionary) + else { + return nil + } + + self.identifier = identifier + self.spaces = spaces + self.currentSpace = currentSpace + } + } + + struct ManagedSpace { + let id: UInt64 + let managedID: UInt64 + let type: UInt64 + let uuid: UUID? + + var isDesktop: Bool { + type == 0 + } + + init?(dictionary: NSDictionary?) { + guard + let dictionary, + let id = dictionary["id64"] as? UInt64, + let managedID = dictionary["ManagedSpaceID"] as? UInt64, + let type = dictionary["type"] as? UInt64 + else { + return nil + } + + self.id = id + self.managedID = managedID + self.type = type + self.uuid = UUID(uuidString: dictionary["uuid"] as? String ?? "") + } + } + /// Brings the window’s owning process to the front using SkyLight APIs. /// - Parameters: /// - windowID: The `CGWindowID` of the window to make the frontmost process. @@ -211,6 +258,116 @@ enum SkyLightToolBelt { return level } + /// Moves the window to a Mission Control desktop space. + /// - Parameters: + /// - windowID: The `CGWindowID` of the window to move. + /// - spaceID: The target managed space id64. + /// - Returns: Whether the window is on, or was moved to, the target space. + @available(macOS 14.0, *) + @discardableResult + static func moveWindow(_ windowID: CGWindowID, toSpace spaceID: UInt64) -> Bool { + if copySpaces(forWindows: [windowID]).contains(spaceID) { + return true + } + + let moved = SkyLightBridgedSPI.moveWindow(windowID, toSpace: spaceID) + if !moved { + log.error("SkyLight bridged window movement is unavailable") + } + + return moved + } + + /// Returns all managed displays and their spaces in Mission Control order. + @available(macOS 14.0, *) + static func copyDisplaysWithSpaces() -> [ManagedDisplay] { + guard let result = SkyLightBridgedSPI.copyManagedDisplaySpaces() else { + log.error("SkyLight bridged display space lookup is unavailable") + return [] + } + + return result.compactMap(ManagedDisplay.init(dictionary:)) + } + + /// Returns the largest number of regular desktop spaces on any managed display. + @available(macOS 14.0, *) + static func maximumDesktopCount() -> Int { + copyDisplaysWithSpaces() + .map { display in + display.spaces.filter(\.isDesktop).count + } + .max() ?? 0 + } + + /// Returns the managed space ids for the given windows. + @available(macOS 14.0, *) + static func copySpaces(forWindows windowIDs: [CGWindowID]) -> [UInt64] { + guard let numbers = SkyLightBridgedSPI.copySpaces(forWindows: windowIDs, options: .allManaged) else { + log.error("SkyLight bridged window space lookup is unavailable") + return [] + } + + return numbers.map { UInt64(truncating: $0) } + } + + /// Returns the first managed space id for a window, or `nil` if SkyLight cannot resolve it. + @available(macOS 14.0, *) + static func copySpace(forWindow windowID: CGWindowID) -> UInt64? { + let spaceID = copySpaces(forWindows: [windowID]).first ?? 0 + return spaceID == 0 ? nil : spaceID + } + + /// Resolves the desktop space adjacent to the window's current desktop. + /// - Parameters: + /// - windowID: The target window. + /// - offset: `-1` for previous desktop, `1` for next desktop. + @available(macOS 14.0, *) + static func desktopSpace(forWindow windowID: CGWindowID, offset: Int) -> ManagedSpace? { + guard offset != 0, + let currentSpaceID = copySpace(forWindow: windowID), + let display = copyDisplaysWithSpaces().first(where: { display in + display.spaces.contains { $0.id == currentSpaceID } + }) + else { + return nil + } + + let desktops = display.spaces.filter(\.isDesktop) + guard let currentIndex = desktops.firstIndex(where: { $0.id == currentSpaceID }) else { + log.error("Window \(windowID) is not on a regular desktop space") + return nil + } + + let targetIndex = currentIndex + offset + guard desktops.indices.contains(targetIndex) else { + return nil + } + + return desktops[targetIndex] + } + + /// Resolves a 1-based desktop number on the display hosting the window's current space. + @available(macOS 14.0, *) + static func desktopSpace(forWindow windowID: CGWindowID, desktopNumber: UInt) -> ManagedSpace? { + guard desktopNumber > 0, + let currentSpaceID = copySpace(forWindow: windowID), + let display = copyDisplaysWithSpaces().first(where: { display in + display.spaces.contains { $0.id == currentSpaceID } + }) + else { + return nil + } + + let desktops = display.spaces.filter(\.isDesktop) + let targetIndex = Int(desktopNumber - 1) + + guard desktops.indices.contains(targetIndex) else { + return nil + } + + return desktops[targetIndex] + } + /// Retrieves the corner radii for a specific window. /// - Parameter windowID: The `CGWindowID` of the window /// - Returns: The corner radii of the window if the operation was successful, or `nil` otherwise. diff --git a/Loop/Utilities/PickerList.swift b/Loop/Utilities/PickerList.swift index 3d75e1de..4c46608c 100644 --- a/Loop/Utilities/PickerList.swift +++ b/Loop/Utilities/PickerList.swift @@ -179,14 +179,20 @@ struct PickerSection: Identifiable, Hashable where V: Hashable, V: Identifiab extension PickerSection where V == WindowDirection { static var windowDirections: [PickerSection] { - [ + let spaceSwitching = visibleSpaceSwitchingDirections() + let spaceSwitchingSections: [PickerSection] = spaceSwitching.isEmpty ? [] : [ + .init(String(localized: "Space Switching", comment: "Section header in the action picker of the Keybinds tab"), spaceSwitching) + ] + + return [ .init(String(localized: "General", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.general), .init(String(localized: "Halves", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.halves), .init(String(localized: "Quarters", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.quarters), .init(String(localized: "Horizontal Thirds", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.horizontalThirds), .init(String(localized: "Vertical Thirds", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.verticalThirds), .init(String(localized: "Horizontal Fourths", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.horizontalFourths), - .init(String(localized: "Screen Switching", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.screenSwitching), + .init(String(localized: "Screen Switching", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.screenSwitching) + ] + spaceSwitchingSections + [ .init(String(localized: "Size Adjustment", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.sizeAdjustment), .init(String(localized: "Shrink", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.shrink), .init(String(localized: "Grow", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.grow), @@ -196,4 +202,20 @@ extension PickerSection where V == WindowDirection { .init(String(localized: "Go Back", comment: "Section header in the action picker of the Keybinds tab"), [WindowDirection.initialFrame, WindowDirection.undo]) ] } + + private static func visibleSpaceSwitchingDirections() -> [WindowDirection] { + guard #available(macOS 14.0, *) else { + return [] + } + + let maximumDesktopCount = SkyLightToolBelt.maximumDesktopCount() + guard maximumDesktopCount > 1 else { + return [] + } + + let visibleNumberedActions = WindowDirection.numberedSpaceSwitching + .prefix(maximumDesktopCount) + + return WindowDirection.relativeSpaceSwitching + visibleNumberedActions + } } diff --git a/Loop/Window Management/Window Action/IconView.swift b/Loop/Window Management/Window Action/IconView.swift index 413fc167..eeac548c 100644 --- a/Loop/Window Management/Window Action/IconView.swift +++ b/Loop/Window Management/Window Action/IconView.swift @@ -190,7 +190,7 @@ final class IconRenderView: NSView { animatePath(layer: fillLayer, to: newPath, duration: duration) case let .image(image): imageLayer.contents = processImage(image, color: .textColor) - imageLayer.frame = getImageBounds() + imageLayer.frame = getImageBounds(for: image) animateAlpha(layer: fillLayer, to: 0, duration: duration) animateAlpha(layer: imageLayer, to: 1, duration: duration) } @@ -290,14 +290,28 @@ final class IconRenderView: NSView { return sizedImage } - private func getImageBounds() -> NSRect { + private func getImageBounds(for image: NSImage) -> NSRect { let insetBounds = bounds.insetBy(dx: strokeWidth, dy: strokeWidth) - let side = min(insetBounds.width, insetBounds.height) + let imageAspectRatio = image.size.width / max(image.size.height, 1) + let boundsAspectRatio = insetBounds.width / max(insetBounds.height, 1) + + let imageSize = if imageAspectRatio > boundsAspectRatio { + CGSize( + width: insetBounds.width, + height: insetBounds.width / imageAspectRatio + ) + } else { + CGSize( + width: insetBounds.height * imageAspectRatio, + height: insetBounds.height + ) + } + let squareRect = CGRect( - x: insetBounds.midX - side / 2, - y: insetBounds.midY - side / 2, - width: side, - height: side + x: insetBounds.midX - imageSize.width / 2, + y: insetBounds.midY - imageSize.height / 2, + width: imageSize.width, + height: imageSize.height ) return squareRect } diff --git a/Loop/Window Management/Window Action/WindowAction+Image.swift b/Loop/Window Management/Window Action/WindowAction+Image.swift index 54520a2a..d6f4ca99 100644 --- a/Loop/Window Management/Window Action/WindowAction+Image.swift +++ b/Loop/Window Management/Window Action/WindowAction+Image.swift @@ -11,6 +11,7 @@ import SwiftUI enum WindowActionImage { case systemImage(String) case resource(ImageResource) + case number(Int) var image: Image { switch self { @@ -18,6 +19,8 @@ enum WindowActionImage { Image(systemName: string) case let .resource(resource): Image(resource) + case .number: + Image(nsImage: nsImage) } } @@ -28,13 +31,51 @@ enum WindowActionImage { return image?.withSymbolConfiguration(.init(pointSize: 20, weight: .bold)) ?? image ?? NSImage() case let .resource(resource): return NSImage(resource: resource) + case let .number(number): + return Self.numberImage(number) } } + + private static func numberImage(_ number: Int) -> NSImage { + let size = CGSize(width: 22, height: 16) + let image = NSImage(size: size) + + image.lockFocus() + defer { + image.unlockFocus() + image.isTemplate = true + } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 14, weight: .bold), + .foregroundColor: NSColor.black, + .paragraphStyle: paragraphStyle + ] + + let text = "\(number)" as NSString + let textSize = text.size(withAttributes: attributes) + let textRect = CGRect( + x: 0, + y: (size.height - textSize.height) / 2, + width: size.width, + height: textSize.height + ) + text.draw(in: textRect, withAttributes: attributes) + + return image + } } extension WindowAction { var image: WindowActionImage? { - switch direction { + if let desktopNumber = direction.spaceDestination?.desktopNumber { + return .number(Int(desktopNumber)) + } + + return switch direction { case .noAction: .systemImage("questionmark") case .undo: @@ -51,9 +92,9 @@ extension WindowAction { .systemImage("arrow.up.and.down") case .maximizeWidth: .systemImage("arrow.left.and.right") - case .nextScreen: + case .nextScreen, .nextSpace: .systemImage("arrow.forward") - case .previousScreen: + case .previousScreen, .previousSpace: .systemImage("arrow.backward") case .leftScreen: .systemImage("arrow.left.to.line") diff --git a/Loop/Window Management/Window Action/WindowDirection+LocalizedString.swift b/Loop/Window Management/Window Action/WindowDirection+LocalizedString.swift index 7d501c3f..9be9796d 100644 --- a/Loop/Window Management/Window Action/WindowDirection+LocalizedString.swift +++ b/Loop/Window Management/Window Action/WindowDirection+LocalizedString.swift @@ -111,6 +111,42 @@ extension WindowDirection { String(localized: "Top Screen", comment: "Window action") case .bottomScreen: String(localized: "Bottom Screen", comment: "Window action") + case .nextSpace: + String(localized: "Move to Next Space", comment: "Window action") + case .previousSpace: + String(localized: "Move to Previous Space", comment: "Window action") + case .moveToSpace1: + String(localized: "Move to Desktop 1", comment: "Window action") + case .moveToSpace2: + String(localized: "Move to Desktop 2", comment: "Window action") + case .moveToSpace3: + String(localized: "Move to Desktop 3", comment: "Window action") + case .moveToSpace4: + String(localized: "Move to Desktop 4", comment: "Window action") + case .moveToSpace5: + String(localized: "Move to Desktop 5", comment: "Window action") + case .moveToSpace6: + String(localized: "Move to Desktop 6", comment: "Window action") + case .moveToSpace7: + String(localized: "Move to Desktop 7", comment: "Window action") + case .moveToSpace8: + String(localized: "Move to Desktop 8", comment: "Window action") + case .moveToSpace9: + String(localized: "Move to Desktop 9", comment: "Window action") + case .moveToSpace10: + String(localized: "Move to Desktop 10", comment: "Window action") + case .moveToSpace11: + String(localized: "Move to Desktop 11", comment: "Window action") + case .moveToSpace12: + String(localized: "Move to Desktop 12", comment: "Window action") + case .moveToSpace13: + String(localized: "Move to Desktop 13", comment: "Window action") + case .moveToSpace14: + String(localized: "Move to Desktop 14", comment: "Window action") + case .moveToSpace15: + String(localized: "Move to Desktop 15", comment: "Window action") + case .moveToSpace16: + String(localized: "Move to Desktop 16", comment: "Window action") case .larger: String(localized: "Larger", comment: "Window action") case .smaller: diff --git a/Loop/Window Management/Window Action/WindowDirection.swift b/Loop/Window Management/Window Action/WindowDirection.swift index ac2d750c..1e1aadaa 100644 --- a/Loop/Window Management/Window Action/WindowDirection.swift +++ b/Loop/Window Management/Window Action/WindowDirection.swift @@ -48,6 +48,13 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { /// Screen Switching case nextScreen = "NextScreen", previousScreen = "PreviousScreen", leftScreen = "LeftScreen", rightScreen = "RightScreen", topScreen = "TopScreen", bottomScreen = "BottomScreen" + /// Space Switching (move the window to another Mission Control space). + case nextSpace = "NextSpace", previousSpace = "PreviousSpace" + case moveToSpace1 = "MoveToSpace1", moveToSpace2 = "MoveToSpace2", moveToSpace3 = "MoveToSpace3", moveToSpace4 = "MoveToSpace4" + case moveToSpace5 = "MoveToSpace5", moveToSpace6 = "MoveToSpace6", moveToSpace7 = "MoveToSpace7", moveToSpace8 = "MoveToSpace8" + case moveToSpace9 = "MoveToSpace9", moveToSpace10 = "MoveToSpace10", moveToSpace11 = "MoveToSpace11", moveToSpace12 = "MoveToSpace12" + case moveToSpace13 = "MoveToSpace13", moveToSpace14 = "MoveToSpace14", moveToSpace15 = "MoveToSpace15", moveToSpace16 = "MoveToSpace16" + // Size Adjustment case larger = "Larger", smaller = "Smaller" case scaleUp = "ScaleUp", scaleDown = "ScaleDown" @@ -79,6 +86,19 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { static var verticalThirds: [WindowDirection] { [.topThird, .topTwoThirds, .verticalCenterThird, .bottomTwoThirds, .bottomThird] } static var horizontalFourths: [WindowDirection] { [.firstFourth, .secondFourth, .thirdFourth, .fourthFourth, .leftThreeFourths, .rightThreeFourths] } static var screenSwitching: [WindowDirection] { [.nextScreen, .previousScreen, .leftScreen, .rightScreen, .topScreen, .bottomScreen] } + static var spaceSwitching: [WindowDirection] { + [ + .nextSpace, .previousSpace, + .moveToSpace1, .moveToSpace2, .moveToSpace3, .moveToSpace4, + .moveToSpace5, .moveToSpace6, .moveToSpace7, .moveToSpace8, + .moveToSpace9, .moveToSpace10, .moveToSpace11, .moveToSpace12, + .moveToSpace13, .moveToSpace14, .moveToSpace15, .moveToSpace16 + ] + } + + static var relativeSpaceSwitching: [WindowDirection] { [.nextSpace, .previousSpace] } + static var numberedSpaceSwitching: [WindowDirection] { Array(spaceSwitching.dropFirst(relativeSpaceSwitching.count)) } + static var sizeAdjustment: [WindowDirection] { [.larger, .smaller, .scaleUp, .scaleDown] } static var shrink: [WindowDirection] { [.shrinkTop, .shrinkBottom, .shrinkRight, .shrinkLeft, .shrinkHorizontal, .shrinkVertical] } static var grow: [WindowDirection] { [.growTop, .growBottom, .growRight, .growLeft, .growHorizontal, .growVertical] } @@ -89,6 +109,7 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { // Computed properties for checking conditions var isNoOp: Bool { [.noSelection, .noAction].contains(self) } var willChangeScreen: Bool { WindowDirection.screenSwitching.contains(self) } + var willChangeSpace: Bool { WindowDirection.spaceSwitching.contains(self) } var willAdjustSize: Bool { WindowDirection.sizeAdjustment.contains(self) } var willShrink: Bool { WindowDirection.shrink.contains(self) } var willGrow: Bool { WindowDirection.grow.contains(self) } @@ -99,7 +120,7 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { var hasRadialMenuAngle: Bool { let noAngleActions: [WindowDirection] = [.noAction, .noSelection, .minimize, .minimizeOthers, .hide, .initialFrame, .undo, .cycle] - return !(noAngleActions.contains(self) || shouldFillRadialMenu || willChangeScreen || willAdjustSize || willShrink || willGrow || willMove || willFocusWindow) + return !(noAngleActions.contains(self) || shouldFillRadialMenu || willChangeScreen || willChangeSpace || willAdjustSize || willShrink || willGrow || willMove || willFocusWindow) } var shouldFillRadialMenu: Bool { @@ -155,6 +176,31 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { default: nil } } + + /// If this direction targets a Mission Control space, returns the resolved destination. + var spaceDestination: WindowActionEngine.SpaceDestination? { + switch self { + case .nextSpace: .nextSpace + case .previousSpace: .previousSpace + case .moveToSpace1: .desktop(1) + case .moveToSpace2: .desktop(2) + case .moveToSpace3: .desktop(3) + case .moveToSpace4: .desktop(4) + case .moveToSpace5: .desktop(5) + case .moveToSpace6: .desktop(6) + case .moveToSpace7: .desktop(7) + case .moveToSpace8: .desktop(8) + case .moveToSpace9: .desktop(9) + case .moveToSpace10: .desktop(10) + case .moveToSpace11: .desktop(11) + case .moveToSpace12: .desktop(12) + case .moveToSpace13: .desktop(13) + case .moveToSpace14: .desktop(14) + case .moveToSpace15: .desktop(15) + case .moveToSpace16: .desktop(16) + default: nil + } + } } extension WindowDirection: CustomDebugStringConvertible { diff --git a/Loop/Window Management/Window Manipulation/WindowActionEngine.swift b/Loop/Window Management/Window Manipulation/WindowActionEngine.swift index 308d097d..d1f3c398 100644 --- a/Loop/Window Management/Window Manipulation/WindowActionEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowActionEngine.swift @@ -111,6 +111,12 @@ final class WindowActionEngine { return handleFocusAction(context.action, currentWindow: context.window) } + // Cross-space throws + if let window = context.window, let destination = direction.spaceDestination { + await throwWindow(window, to: destination) + return .noOp + } + // Quick actions that don't require resize logic if let result = handleQuickAction(context.action, window: context.window) { return result @@ -181,4 +187,50 @@ final class WindowActionEngine { window.minimized = true } } + + // MARK: - Cross-Space Window Throw + + enum SpaceDestination { + case previousSpace + case nextSpace + case desktop(UInt) + + var description: String { + switch self { + case .previousSpace: "previous space" + case .nextSpace: "next space" + case let .desktop(n): "desktop \(n)" + } + } + + var desktopNumber: UInt? { + if case let .desktop(number) = self { + return number + } + return nil + } + } + + private func throwWindow(_ window: Window, to space: SpaceDestination) async { + guard #available(macOS 14.0, *) else { + log.error("Cross-space window movement requires macOS 14.0 or later") + return + } + + let targetSpace: SkyLightToolBelt.ManagedSpace? = switch space { + case .previousSpace: + SkyLightToolBelt.desktopSpace(forWindow: window.cgWindowID, offset: -1) + case .nextSpace: + SkyLightToolBelt.desktopSpace(forWindow: window.cgWindowID, offset: 1) + case let .desktop(number): + SkyLightToolBelt.desktopSpace(forWindow: window.cgWindowID, desktopNumber: number) + } + + guard let targetSpace else { + log.error("Could not resolve target \(space.description) for window \(window.cgWindowID)") + return + } + + SkyLightToolBelt.moveWindow(window.cgWindowID, toSpace: targetSpace.id) + } }