diff --git a/Loop/Extensions/AXUIElement+Extensions.swift b/Loop/Extensions/AXUIElement+Extensions.swift index 1239463f..2203ae65 100644 --- a/Loop/Extensions/AXUIElement+Extensions.swift +++ b/Loop/Extensions/AXUIElement+Extensions.swift @@ -37,6 +37,15 @@ extension AXUIElement { } } + func canSetValue(_ attribute: NSAccessibility.Attribute) throws -> Bool { + var isSettable = DarwinBoolean(false) + let error = AXUIElementIsAttributeSettable(self, attribute as CFString, &isSettable) + guard error == .success else { + throw error + } + return isSettable.boolValue + } + func getElementAtPosition(_ position: CGPoint) throws -> AXUIElement? { var element: AXUIElement? let error = AXUIElementCopyElementAtPosition(self, Float(position.x), Float(position.y), &element) diff --git a/Loop/Extensions/CGGeometry+Extensions.swift b/Loop/Extensions/CGGeometry+Extensions.swift index 62d83854..67a639f9 100644 --- a/Loop/Extensions/CGGeometry+Extensions.swift +++ b/Loop/Extensions/CGGeometry+Extensions.swift @@ -52,6 +52,19 @@ extension CGSize { func approximatelyEqual(to size: CGSize, tolerance: CGFloat = 10) -> Bool { abs(width - size.width) < tolerance && abs(height - size.height) < tolerance } + + func center(inside parentRect: CGRect) -> CGRect { + let parentRectCenter = parentRect.center + let newX = parentRectCenter.x - width / 2 + let newY = parentRectCenter.y - height / 2 + + return CGRect( + x: newX, + y: newY, + width: width, + height: height + ) + } } extension CGRect { @@ -99,9 +112,17 @@ extension CGRect { abs(height - rect.height) < tolerance } - func pushBottomRightPointInside(_ rect2: CGRect) -> CGRect { + func pushInside(_ rect2: CGRect) -> CGRect { var result = self + if result.minX < rect2.minX { + result.origin.x = rect2.minX + } + + if result.minY < rect2.minY { + result.origin.y = rect2.minY + } + if result.maxX > rect2.maxX { result.origin.x = rect2.maxX - result.width } diff --git a/Loop/Managers/LoopManager.swift b/Loop/Managers/LoopManager.swift index 4b0e52ad..54a74cfe 100644 --- a/Loop/Managers/LoopManager.swift +++ b/Loop/Managers/LoopManager.swift @@ -14,7 +14,6 @@ class LoopManager: ObservableObject { // Size Adjustment static var sidesToAdjust: Edge.Set? static var lastTargetFrame: CGRect = .zero - static var canAdjustSize: Bool = true private let keybindMonitor = KeybindMonitor.shared @@ -168,7 +167,6 @@ private extension LoopManager { isLoopActive { if let screenToResizeOn, Defaults[.previewVisibility] { - LoopManager.canAdjustSize = false WindowEngine.resize( targetWindow!, to: currentAction, @@ -191,7 +189,6 @@ private extension LoopManager { isLoopActive = false LoopManager.sidesToAdjust = nil LoopManager.lastTargetFrame = .zero - LoopManager.canAdjustSize = true } func openWindows() { diff --git a/Loop/Managers/WindowDragManager.swift b/Loop/Managers/WindowDragManager.swift index 1280d85d..66baf756 100644 --- a/Loop/Managers/WindowDragManager.swift +++ b/Loop/Managers/WindowDragManager.swift @@ -104,7 +104,7 @@ class WindowDragManager { if let screen = NSScreen.screenWithMouse { var newWindowFrame = window.frame newWindowFrame.size = initialFrame.size - newWindowFrame = newWindowFrame.pushBottomRightPointInside(screen.frame) + newWindowFrame = newWindowFrame.pushInside(screen.frame) window.setFrame(newWindowFrame) } else { window.size = initialFrame.size diff --git a/Loop/Window Management/Window.swift b/Loop/Window Management/Window.swift index 8de5de94..49200d68 100644 --- a/Loop/Window Management/Window.swift +++ b/Loop/Window Management/Window.swift @@ -29,6 +29,8 @@ class Window { var observer: Observer? + /// Initialize a window from an AXUIElement + /// - Parameter element: The AXUIElement to initialize the window with. If it is not a window, an error will be thrown init(element: AXUIElement) throws { self.axWindow = element @@ -51,6 +53,8 @@ class Window { } } + /// Initialize a window from a PID. The frontmost app with the given PID will be used. + /// - Parameter pid: The PID of the app to get the window from convenience init(pid: pid_t) throws { let element = AXUIElementCreateApplication(pid) guard let window: AXUIElement = try element.getValue(.focusedWindow) else { @@ -125,6 +129,7 @@ class Window { } } + /// Activate the window. This will bring it to the front and focus it if possible func activate() { do { try self.axWindow.setValue(.main, value: true) @@ -255,15 +260,32 @@ class Window { } } + var isResizable: Bool { + do { + let result: Bool = try self.axWindow.canSetValue(.size) + return result + } catch { + print("Failed to determine if window size can be set: \(error.localizedDescription)") + return true + } + } + var frame: CGRect { CGRect(origin: self.position, size: self.size) } + /// Set the frame of this Window. + /// - Parameters: + /// - rect: The new frame for the window + /// - animate: Whether or not to animate the window resizing + /// - sizeFirst: This will set the size first, which is useful when switching screens. Only does something when window animations are off + /// - bounds: This will prevent the window from going outside the bounds. Only does something when window animations are on + /// - completionHandler: Something to run after the window has been resized. This can include things like moving the cursor to the center of the window func setFrame( _ rect: CGRect, animate: Bool = false, - sizeFirst: Bool = false, // Only does something when window animations are off - bounds: CGRect = .zero, // Only does something when window animations are on + sizeFirst: Bool = false, + bounds: CGRect = .zero, completionHandler: @escaping (() -> ()) = {} ) { let enhancedUI = self.enhancedUserInterface diff --git a/Loop/Window Management/WindowAction.swift b/Loop/Window Management/WindowAction.swift index 0c304cdc..c2e79029 100644 --- a/Loop/Window Management/WindowAction.swift +++ b/Loop/Window Management/WindowAction.swift @@ -137,122 +137,112 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial guard direction != .cycle, direction != .noAction else { return NSRect(origin: bounds.center, size: .zero) } + var bounds = bounds + var result: CGRect = .zero + + // Get padded bounds only if padding can be applied if !disablePadding && Defaults[.enablePadding], Defaults[.paddingMinimumScreenSize] == .zero || screen?.diagonalSize ?? .zero > Defaults[.paddingMinimumScreenSize] { bounds = getPaddedBounds(bounds) } - var result = CGRect(origin: bounds.origin, size: .zero) if !willManipulateExistingWindowFrame { LoopManager.sidesToAdjust = nil } - if let frameMultiplyValues = direction.frameMultiplyValues { - result.origin.x += bounds.width * frameMultiplyValues.minX - result.origin.y += bounds.height * frameMultiplyValues.minY - result.size.width = bounds.width * frameMultiplyValues.width - result.size.height = bounds.height * frameMultiplyValues.height + result = calculateTargetFrame(direction, window, bounds) - } else if direction.willAdjustSize { - let frameToResizeFrom = LoopManager.lastTargetFrame + if !disablePadding { + // If window can't be resized, center it within the already-resized frame. + if let window, window.isResizable == false { + result = window.frame.size + .center(inside: result) + .pushInside(bounds) + } - result = frameToResizeFrom - if LoopManager.canAdjustSize { - result = calculateSizeAdjustment(frameToResizeFrom, bounds) + // Apply padding between windows + if direction != .undo, direction != .initialFrame { + result = applyInnerPadding(result, bounds) } + // Store the last target frame. This is used when growing/shrinking windows + // We only store it when disablePadding is false, as otherwise, it is going to be the preview window using this frame. + LoopManager.lastTargetFrame = result + } + + return result + } +} + +// MARK: - Window Frame Calculations + +private extension WindowAction { + func calculateTargetFrame(_ direction: WindowDirection, _ window: Window?, _ bounds: CGRect) -> CGRect { + var result: CGRect = .zero + + if direction.frameMultiplyValues != nil { + result = applyFrameMultiplyValues(bounds) + + } else if direction.willAdjustSize { + let frameToResizeFrom = LoopManager.lastTargetFrame + result = calculateSizeAdjustment(frameToResizeFrom, bounds) + } else if direction.willShrink || direction.willGrow { // This allows for control over each side let frameToResizeFrom = LoopManager.lastTargetFrame - result = frameToResizeFrom - if LoopManager.canAdjustSize { - switch direction { - case .shrinkTop, .growTop: - LoopManager.sidesToAdjust = .top - case .shrinkBottom, .growBottom: - LoopManager.sidesToAdjust = .bottom - case .shrinkLeft, .growLeft: - LoopManager.sidesToAdjust = .leading - default: - LoopManager.sidesToAdjust = .trailing - } - - result = calculateSizeAdjustment(frameToResizeFrom, bounds) + // calculateSizeAdjustment() will read LoopManager.sidesToAdjust, but we compute them here + switch direction { + case .shrinkTop, .growTop: + LoopManager.sidesToAdjust = .top + case .shrinkBottom, .growBottom: + LoopManager.sidesToAdjust = .bottom + case .shrinkLeft, .growLeft: + LoopManager.sidesToAdjust = .leading + default: + LoopManager.sidesToAdjust = .trailing } + result = calculateSizeAdjustment(frameToResizeFrom, bounds) + } else if direction.willMove { let frameToResizeFrom = LoopManager.lastTargetFrame - result = calculatePointAdjustment(frameToResizeFrom) + result = calculatePositionAdjustment(frameToResizeFrom) } else if direction == .custom { result = calculateCustomFrame(window, bounds) } else if direction == .center { - let windowSize: CGSize = if let window { - window.size - } else { - .init(width: bounds.width / 2, height: bounds.height / 2) - } - - result = CGRect( - origin: CGPoint( - x: bounds.midX - (windowSize.width / 2), - y: bounds.midY - (windowSize.height / 2) - ), - size: windowSize - ) + result = calculateCenterFrame(window, bounds) } else if direction == .macOSCenter { - let windowSize: CGSize = if let window { - window.size - } else { - .init(width: bounds.width / 2, height: bounds.height / 2) - } + result = calculateMacOSCenterFrame(window, bounds) - let yOffset = WindowEngine.getMacOSCenterYOffset( - windowSize.height, - screenHeight: bounds.height - ) - - result = CGRect( - origin: CGPoint( - x: bounds.midX - (windowSize.width / 2), - y: bounds.midY - (windowSize.height / 2) + yOffset - ), - size: windowSize - ) } else if direction == .undo, let window { - if let previousAction = WindowRecords.getLastAction(for: window) { - print("Last action was \(previousAction.direction) (name: \(previousAction.name ?? "nil"))") - result = previousAction.getFrame(window: window, bounds: bounds) - } else { - print("Didn't find frame to undo; using current frame") - result = window.frame - } + result = getLastActionFrame(window, bounds) } else if direction == .initialFrame, let window { - if let initialFrame = WindowRecords.getInitialFrame(for: window) { - result = initialFrame - } else { - print("Didn't find initial frame; using current frame") - result = window.frame - } + result = getInitialFrame(window) } - if !disablePadding { - if direction != .undo, direction != .initialFrame { - result = cropThenApplyInnerPadding(result, bounds) - } + return result + } - LoopManager.lastTargetFrame = result + func applyFrameMultiplyValues(_ bounds: CGRect) -> CGRect { + guard let frameMultiplyValues = direction.frameMultiplyValues else { + return .zero } - return result + return CGRect( + x: bounds.origin.x + (bounds.width * frameMultiplyValues.minX), + y: bounds.origin.y + (bounds.height * frameMultiplyValues.minY), + width: bounds.width * frameMultiplyValues.width, + height: bounds.height * frameMultiplyValues.height + ) } - private func calculateCustomFrame(_ window: Window?, _ bounds: CGRect) -> CGRect { + func calculateCustomFrame(_ window: Window?, _ bounds: CGRect) -> CGRect { var result = CGRect(origin: bounds.origin, size: .zero) // SIZE @@ -342,7 +332,63 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial return result } - private func calculateSizeAdjustment(_ frameToResizeFrom: CGRect, _ bounds: CGRect) -> CGRect { + func calculateCenterFrame(_ window: Window?, _ bounds: CGRect) -> CGRect { + let windowSize: CGSize = if let window { + window.size + } else { + .init(width: bounds.width / 2, height: bounds.height / 2) + } + + return CGRect( + origin: CGPoint( + x: bounds.midX - (windowSize.width / 2), + y: bounds.midY - (windowSize.height / 2) + ), + size: windowSize + ) + } + + func calculateMacOSCenterFrame(_ window: Window?, _ bounds: CGRect) -> CGRect { + let windowSize: CGSize = if let window { + window.size + } else { + .init(width: bounds.width / 2, height: bounds.height / 2) + } + + let yOffset = WindowEngine.getMacOSCenterYOffset( + windowSize.height, + screenHeight: bounds.height + ) + + return CGRect( + origin: CGPoint( + x: bounds.midX - (windowSize.width / 2), + y: bounds.midY - (windowSize.height / 2) + yOffset + ), + size: windowSize + ) + } + + func getLastActionFrame(_ window: Window, _ bounds: CGRect) -> CGRect { + if let previousAction = WindowRecords.getLastAction(for: window) { + print("Last action was \(previousAction.direction) (name: \(previousAction.name ?? "nil"))") + return previousAction.getFrame(window: window, bounds: bounds) + } else { + print("Didn't find frame to undo; using current frame") + return window.frame + } + } + + func getInitialFrame(_ window: Window) -> CGRect { + if let initialFrame = WindowRecords.getInitialFrame(for: window) { + return initialFrame + } else { + print("Didn't find initial frame; using current frame") + return window.frame + } + } + + func calculateSizeAdjustment(_ frameToResizeFrom: CGRect, _ bounds: CGRect) -> CGRect { var result = frameToResizeFrom let totalBounds: Edge.Set = [.top, .bottom, .leading, .trailing] let step = Defaults[.sizeIncrement] * ((direction == .larger || direction.willGrow) ? -1 : 1) @@ -390,7 +436,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial return result } - private func calculatePointAdjustment(_ frameToResizeFrom: CGRect) -> CGRect { + func calculatePositionAdjustment(_ frameToResizeFrom: CGRect) -> CGRect { var result = frameToResizeFrom if direction == .moveUp { @@ -406,7 +452,8 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial return result } - private func getPaddedBounds(_ bounds: CGRect) -> CGRect { + // This will apply padding to the bounds of the frame + func getPaddedBounds(_ bounds: CGRect) -> CGRect { let padding = Defaults[.padding] var bounds = bounds @@ -418,7 +465,8 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial return bounds } - private func cropThenApplyInnerPadding(_ windowFrame: CGRect, _ bounds: CGRect, _ screen: NSScreen? = nil) -> CGRect { + // This will apply padding within the frame, in between windows + func applyInnerPadding(_ windowFrame: CGRect, _ bounds: CGRect, _ screen: NSScreen? = nil) -> CGRect { guard !direction.willMove else { return windowFrame } diff --git a/Loop/Window Management/WindowEngine.swift b/Loop/Window Management/WindowEngine.swift index 3d6246bf..c32cfbd5 100644 --- a/Loop/Window Management/WindowEngine.swift +++ b/Loop/Window Management/WindowEngine.swift @@ -30,17 +30,21 @@ enum WindowEngine { window.activate() } + // If window hasn't been recorded yet, record it, so that the user can undo the action if !WindowRecords.hasBeenRecorded(window) { WindowRecords.recordFirst(for: window) } + // If the action is fullscreen, toggle fullscreen then return if action.direction == .fullscreen { window.toggleFullscreen() WindowRecords.record(window, action) return } + // Otherwise, we obviously need to disable fullscreen to resize the window window.fullscreen = false + // If the action is to hide or minimize, perform the action then return if action.direction == .hide { window.toggleHidden() return @@ -51,39 +55,33 @@ enum WindowEngine { return } + // Calculate the target frame let targetFrame = action.getFrame(window: window, bounds: screen.safeScreenFrame, screen: screen) + print("Target window frame: \(targetFrame)") + // If the action is undo, remove the last action from the window, as the target frame already contains the last action's size if action.direction == .undo { WindowRecords.removeLastAction(for: window) } - print("Target window frame: \(targetFrame)") - + // If enhancedUI is enabled, then window animations will likely lag a LOT. So, if it's enabled, force-disable animations let enhancedUI = window.enhancedUserInterface let animate = Defaults[.animateWindowResizes] && !enhancedUI + WindowRecords.record(window, action) + // If the window is one of Loop's windows, resize it using the actual NSWindow, preventing crashes if window.nsRunningApplication?.bundleIdentifier == Bundle.main.bundleIdentifier, - let window = NSApp.keyWindow ?? NSApp.windows.first { - var newFrame = targetFrame - newFrame.size = window.frame.size - - if newFrame.maxX > screen.safeScreenFrame.maxX { - newFrame.origin.x = screen.safeScreenFrame.maxX - newFrame.width - Defaults[.padding].right - } - - if newFrame.maxY > screen.safeScreenFrame.maxY { - newFrame.origin.y = screen.safeScreenFrame.maxY - newFrame.height - Defaults[.padding].bottom - } - + let window = NSApp.keyWindow ?? NSApp.windows.first(where: { $0.level.rawValue <= NSWindow.Level.floating.rawValue }) { NSAnimationContext.runAnimationGroup { context in context.timingFunction = CAMediaTimingFunction(controlPoints: 0.33, 1, 0.68, 1) - window.animator().setFrame(newFrame.flipY(screen: .screens[0]), display: false) + window.animator().setFrame(targetFrame.flipY(screen: .screens[0]), display: false) } - return } + // If the window is being moved via shortcuts (move right, move left etc.), then the screenFrame will be zero. + // This is because the window *can* be moved off-screen in this case. let screenFrame = action.direction.willMove ? .zero : screen.safeScreenFrame let bounds = if Defaults[.enablePadding], @@ -99,23 +97,25 @@ enum WindowEngine { sizeFirst: willChangeScreens, bounds: bounds ) { - // If animations are disabled, check if the window needs extra resizing - if !animate { - // Fixes an issue where window isn't resized correctly on multi-monitor setups - if !window.frame.approximatelyEqual(to: targetFrame) { - print("Backup resizing...") - window.setFrame(targetFrame) - } + // Fixes an issue where window isn't resized correctly on multi-monitor setups + // If window is being animated, then the size is very likely to already be correct, as what's really happening is window.setFrame at a really high rate. + if !animate, !window.frame.approximatelyEqual(to: targetFrame) { + print("Backup resizing...") + window.setFrame(targetFrame) } + // If window's minimum size exceeds the screen bounds, push it back in WindowEngine.handleSizeConstrainedWindow(window: window, bounds: bounds) } + // Move cursor to center of window if user has enabled it if Defaults[.moveCursorWithWindow] { CGWarpMouseCursorPosition(targetFrame.center) } } + /// Get the target window, depending on the user's preferences. This could be the frontmost window, or the window under the cursor. + /// - Returns: The target window static func getTargetWindow() -> Window? { var result: Window? @@ -149,12 +149,17 @@ enum WindowEngine { return try Window(pid: app.processIdentifier) } + /// Get the Window at a given position. + /// - Parameter position: The position to check for + /// - Returns: The window at the given position, if any static func windowAtPosition(_ position: CGPoint) throws -> Window? { + // If we can find the window at a point using the Accessibility API, return it if let element = try AXUIElement.systemWide.getElementAtPosition(position), let windowElement: AXUIElement = try element.getValue(.window) { return try Window(element: windowElement) } + // If the previous method didn't work, loop through all windows on-screen and return the first one that contains the desired point let windowList = WindowEngine.windowList if let window = (windowList.first { $0.frame.contains(position) }) { return window @@ -163,6 +168,7 @@ enum WindowEngine { return nil } + /// Get a list of all windows currently shown, that are likely to be resizable by Loop. static var windowList: [Window] { guard let list = CGWindowListCopyWindowInfo( [.optionOnScreenOnly, .excludeDesktopElements], @@ -173,19 +179,20 @@ enum WindowEngine { var windowList: [Window] = [] for window in list { - if let pid = window[kCGWindowOwnerPID as String] as? Int32 { - do { - let window = try Window(pid: pid) - windowList.append(window) - } catch { - print("Failed to create window: \(error.localizedDescription)") - } + if let pid = window[kCGWindowOwnerPID as String] as? Int32, let window = try? Window(pid: pid) { + windowList.append(window) } } return windowList } + /// This function is used to calculate the Y offset for a window to be "macOS centered" on the screen + /// It is identical to `NSWindow.center()`. + /// - Parameters: + /// - windowHeight: Height of the window to be resized + /// - screenHeight: Height of the screen the window will be resized on + /// - Returns: The Y offset of the window, to be added onto the screen's midY point. static func getMacOSCenterYOffset(_ windowHeight: CGFloat, screenHeight: CGFloat) -> CGFloat { let halfScreenHeight = screenHeight / 2 let windowHeightPercent = windowHeight / screenHeight