diff --git a/.swiftformat b/.swiftformat index 5abd0ee1..d350def8 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,87 +1,128 @@ --acronyms ID,URL,UUID --allman false ---anonymousforeach convert ---assetliterals visual-width ---asynccapturing ---beforemarks ---binarygrouping 4,8 ---categorymark "MARK: %c" ---classthreshold 0 ---closingparen balanced ---closurevoid remove ---commas inline ---conflictmarkers reject ---decimalgrouping 3,6 ---doccomments before-declarations ---elseposition same-line ---emptybraces no-space ---enumnamespaces always ---enumthreshold 0 ---exponentcase lowercase ---exponentgrouping disabled ---extensionacl on-extension ---extensionlength 0 ---extensionmark "MARK: - %t + %c" ---fractiongrouping disabled +--allow-partial-wrapping true +--anonymous-for-each convert +--asset-literals visual-width +--async-capturing +--before-marks +--binary-grouping 4,8 +--blank-line-after-switch-case multiline-only +--call-site-paren balanced +--category-mark "MARK: %c" +--class-threshold 0 +--closing-paren balanced +--closure-void remove +--complex-attributes preserve +--computed-var-attributes preserve +--conditional-assignment after-property +--conflict-markers reject +--date-format system +--decimal-grouping 3,6 +--default-test-suite-attributes +--doc-comments before-declarations +--else-position same-line +--empty-braces no-space +--enum-namespaces always +--enum-threshold 0 +--equatable-macro none +--exponent-case lowercase +--exponent-grouping disabled +--extension-acl on-extension +--extension-mark "MARK: - %t + %c" +--extension-threshold 0 +--file-macro "#file" +--fraction-grouping disabled --fragment false ---funcattributes preserve ---generictypes ---groupedextension "MARK: %c" ---guardelse auto +--func-attributes preserve +--generic-types +--group-blank-lines true +--grouped-extension "MARK: %c" +--guard-else auto --header ignore ---hexgrouping 4,8 ---hexliteralcase uppercase +--hex-grouping 4,8 +--hex-literal-case uppercase --ifdef indent ---importgrouping alpha +--import-grouping alpha --indent 4 ---indentcase false ---indentstrings false +--indent-case false +--indent-strings false +--inferred-types always +--init-coder-nil false +--language-mode 0 --lifecycle ---lineaftermarks true +--line-after-marks true +--line-between-guards false --linebreaks lf ---markcategories true ---markextensions always ---marktypes always ---maxwidth none ---modifierorder ---nevertrailing ---nospaceoperators ... ---nowrapoperators ---octalgrouping 4,8 ---onelineforeach ignore ---operatorfunc spaced ---organizetypes actor,class,enum,struct ---patternlet hoist +--mark-categories true +--mark-class-threshold 0 +--mark-enum-threshold 0 +--mark-extension-threshold 0 +--mark-extensions always +--mark-struct-threshold 0 +--mark-types always +--markdown-files ignore +--max-width none +--modifier-order +--never-trailing +--nil-init remove +--no-space-operators ... +--no-wrap-operators +--non-complex-attributes +--octal-grouping 4,8 +--operator-func spaced +--organization-mode visibility +--organize-types actor,class,enum,struct +--pattern-let hoist +--prefer-synthesized-init-for-internal-structs never +--preserve-acronyms +--preserve-decls +--preserved-property-types Package +--property-types infer-locals-only --ranges spaced ---redundanttype infer-locals-only +--redundant-async tests-only +--redundant-throws tests-only --self init-only ---selfrequired ---semicolons inline ---shortoptionals except-properties ---smarttabs enabled ---someany true ---stripunusedargs always ---structthreshold 0 ---swiftversion 6.0 ---tabwidth unspecified ---throwcapturing ---trailingclosures ---trimwhitespace always ---typeattributes preserve ---typeblanklines remove ---typemark "MARK: - %t" ---varattributes preserve ---voidtype tuple ---wraparguments preserve ---wrapcollections preserve ---wrapconditions preserve ---wrapeffects preserve ---wrapenumcases always ---wrapparameters default ---wrapreturntype preserve ---wrapternary default ---wraptypealiases preserve ---xcodeindentation disabled ---yodaswap always ---disable wrapMultilineStatementBraces +--self-required +--semicolons inline-only +--short-optionals except-properties +--single-line-for-each ignore +--smart-tabs enabled +--some-any true +--sort-swiftui-properties none +--sorted-patterns +--stored-var-attributes preserve +--strip-unused-args always +--struct-threshold 0 +--swift-version 6.0 +--tab-width unspecified +--throw-capturing +--timezone system +--trailing-closures +--trailing-commas never +--trim-whitespace always +--type-attributes preserve +--type-blank-lines remove +--type-body-marks preserve +--type-delimiter space-after +--type-mark "MARK: - %t" +--type-marks +--type-order +--url-macro none +--visibility-marks +--visibility-order +--void-type tuple +--wrap-arguments before-first +--wrap-collections preserve +--wrap-conditions preserve +--wrap-effects preserve +--wrap-enum-cases always +--wrap-parameters default +--wrap-return-type preserve +--wrap-string-interpolation default +--wrap-ternary default +--wrap-type-aliases preserve +--xcode-indentation disabled +--xctest-symbols +--yoda-swap always +--disable genericExtensions,redundantProperty,simplifyGenericConstraints,wrapMultilineStatementBraces,wrapPropertyBodies --enable isEmpty diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index ae6350b2..d35f848e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -245,6 +245,8 @@ DEVELOPMENT_TEAM = 5F967GYF84; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -257,6 +259,7 @@ MARKETING_VERSION = "$(VERSION)"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEVELOPMENT; @@ -292,6 +295,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; @@ -345,6 +349,7 @@ DEVELOPMENT_TEAM = 5F967GYF84; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -414,6 +419,7 @@ DEVELOPMENT_TEAM = 5F967GYF84; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -461,6 +467,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; @@ -498,6 +505,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/Loop (GH ACTIONS).xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/Loop (GH ACTIONS).xcscheme index 66f267e3..f674d87f 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/Loop (GH ACTIONS).xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/Loop (GH ACTIONS).xcscheme @@ -1,6 +1,6 @@ NSImage? { let screen = NSScreen.screenWithMouse ?? NSScreen.main ?? NSScreen.screens[0] let screenFrame = screen.displayBounds diff --git a/Loop/Accent Color/WallpaperProcessor.swift b/Loop/Accent Color/WallpaperProcessor.swift index abdad4f3..42661311 100644 --- a/Loop/Accent Color/WallpaperProcessor.swift +++ b/Loop/Accent Color/WallpaperProcessor.swift @@ -44,6 +44,7 @@ enum WallpaperProcessorError: LocalizedError { /// Processes desktop wallpapers to extract colors for theming Loop. /// This class provides methods to capture the current desktop wallpaper and extract /// vibrant, visually appealing colors that can be used as accent colors in the UI. +@Loggable final class WallpaperProcessor { private var lastProcessedDate: Date = .distantPast private var lastResult: (primary: Color, secondary: Color) = (.black, .black) @@ -80,6 +81,7 @@ final class WallpaperProcessor { /// a cohesive theme that matches the user's desktop environment. /// /// Note that you shouldn't call this method directly, but rather, call ``AccentColorController.refresh``. + @concurrent private func fetchLatestWallpaperColors() async -> (primary: Color, secondary: Color)? { do { // Attempt to process the current wallpaper to get the dominant colors. @@ -96,12 +98,12 @@ final class WallpaperProcessor { // Use the second dominant color if possible, otherwise return the primary color. let secondaryColor = colors.count > 1 ? Color(colors[1]) : primaryColor - Log.success("Successfully calculated dominant colors from wallpaper", category: .wallpaperProcessor) + log.success("Successfully calculated dominant colors from wallpaper") return (primaryColor, secondaryColor) } catch { // If an error occurs, print the error description. - Log.error("Failed to fetch wallpaper colors: \(error.localizedDescription)", category: .wallpaperProcessor) + log.error("Failed to fetch wallpaper colors: \(error.localizedDescription)") return nil } } @@ -146,8 +148,7 @@ final class WallpaperProcessor { /// - Incorporates intelligent filtering to avoid colors that would make poor UI accents /// /// The algorithm is optimized for performance while maintaining high-quality color results. - -// The real beans here (I don't like beans) +/// The real beans here (I don't like beans) extension NSImage { /// Calculates the dominant colors of the image asynchronously. /// - Returns: An array of NSColor representing the dominant colors, or nil if an error occurs. @@ -174,7 +175,7 @@ extension NSImage { let dataProvider = resizedCGImage.dataProvider, let data = CFDataGetBytePtr(dataProvider.data) else { - Log.error("Error: \(WallpaperProcessorError.imageResizeFailed)", category: .wallpaperProcessor) + Log.error("Error: \(WallpaperProcessorError.imageResizeFailed)", category: WallpaperProcessor.logCategory) return nil } @@ -292,19 +293,31 @@ extension NSImage { /// - Returns: The resized NSImage or nil if the operation fails. func resized(to newSize: NSSize) -> NSImage? { guard let bitmapRep = NSBitmapImageRep( - bitmapDataPlanes: nil, pixelsWide: Int(newSize.width), - pixelsHigh: Int(newSize.height), bitsPerSample: 8, - samplesPerPixel: 4, hasAlpha: true, isPlanar: false, - colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + bitmapDataPlanes: nil, + pixelsWide: Int(newSize.width), + pixelsHigh: Int(newSize.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 ) else { - Log.error("Error: \(WallpaperProcessorError.bitmapCreationFailed)", category: .wallpaperProcessor) + Log.error("Error: \(WallpaperProcessorError.bitmapCreationFailed)", category: WallpaperProcessor.logCategory) return nil } bitmapRep.size = newSize NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmapRep) - draw(in: NSRect(x: 0, y: 0, width: newSize.width, height: newSize.height), - from: NSRect.zero, operation: .copy, fraction: 1.0, respectFlipped: true, hints: [NSImageRep.HintKey.interpolation: NSNumber(value: NSImageInterpolation.high.rawValue)]) + draw( + in: NSRect(x: 0, y: 0, width: newSize.width, height: newSize.height), + from: NSRect.zero, + operation: .copy, + fraction: 1.0, + respectFlipped: true, + hints: [NSImageRep.HintKey.interpolation: NSNumber(value: NSImageInterpolation.high.rawValue)] + ) NSGraphicsContext.restoreGraphicsState() let resizedImage = NSImage(size: newSize) resizedImage.addRepresentation(bitmapRep) diff --git a/Loop/App/AppDelegate+UNNotifications.swift b/Loop/App/AppDelegate+UNNotifications.swift index ff64e79a..fb3c08fd 100644 --- a/Loop/App/AppDelegate+UNNotifications.swift +++ b/Loop/App/AppDelegate+UNNotifications.swift @@ -23,7 +23,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { completionHandler() } - // Implementation is necessary to show notifications even when the app has focus! + /// Implementation is necessary to show notifications even when the app has focus! func userNotificationCenter( _: UNUserNotificationCenter, willPresent _: UNNotification, @@ -37,11 +37,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate { options: [.alert] ) { accepted, error in if !accepted { - Log.warn("Notification access denied.", category: .appDelegate) + Log.warn("Notification access denied.", category: AppDelegate.logCategory) } if let error { - Log.error("Failed to request notification authorization: \(error.localizedDescription)", category: .appDelegate) + Log.error("Failed to request notification authorization: \(error.localizedDescription)", category: AppDelegate.logCategory) } } } diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index 9c03a9d2..b8002f43 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -10,6 +10,7 @@ import Scribe import SwiftUI import UserNotifications +@Loggable final class AppDelegate: NSObject, NSApplicationDelegate { private let urlCommandHandler = URLCommandHandler() @@ -30,11 +31,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await Defaults.iCloud.waitForSyncCompletion() } - // Wait for other instances to terminate before proceeding with TCC operations - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - AccessibilityManager.requestAccess() - } - if !launchedAsLoginItem { SettingsWindowManager.shared.show() } else { @@ -44,6 +40,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { DataPatcher.run() IconManager.refreshCurrentAppIcon() + LaunchAtLoginManager.shared.start() LoopManager.shared.start() WindowDragManager.shared.addObservers() StashManager.shared.start() @@ -59,6 +56,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { UNUserNotificationCenter.current().delegate = self AppDelegate.requestNotificationAuthorization() + Task { + try? await Task.sleep(for: .seconds(1.5)) + AccessibilityManager.requestAccess() + } + // Register for URL handling NSAppleEventManager.shared().setEventHandler( self, @@ -79,14 +81,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } guard !otherLoopInstances.isEmpty else { - Log.info("No other Loop instances found", category: .appDelegate) + log.info("No other Loop instances found") return } - Log.info("Found \(otherLoopInstances.count) other Loop instance(s), terminating them to prevent accessibility conflicts. TCC operations will be delayed.", category: .appDelegate) + log.info("Found \(otherLoopInstances.count) other Loop instance(s), terminating them to prevent accessibility conflicts. TCC operations will be delayed.") for instance in otherLoopInstances { - Log.info("Terminating Loop instance (PID: \(instance.processIdentifier))", category: .appDelegate) + log.info("Terminating Loop instance (PID: \(instance.processIdentifier))") instance.terminate() // If the instance doesn't terminate within 2 seconds, force terminate @@ -94,7 +96,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { try? await Task.sleep(for: .seconds(2)) if instance.isTerminated == false { - Log.warn("Force terminating Loop instance (PID: \(instance.processIdentifier))", category: .appDelegate) + log.warn("Force terminating Loop instance (PID: \(instance.processIdentifier))") instance.forceTerminate() } } @@ -112,10 +114,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent _: NSAppleEventDescriptor) { guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue, let url = URL(string: urlString) else { - Log.info("Failed to get URL from event", category: .appDelegate) + log.info("Failed to get URL from event") return } - Log.info("Received URL: \(url)", category: .appDelegate) + + log.info("Received URL: \(url)") urlCommandHandler.handle(url) } diff --git a/Loop/App/DataPatcher.swift b/Loop/App/DataPatcher.swift index 5fb6f1bd..ab269979 100644 --- a/Loop/App/DataPatcher.swift +++ b/Loop/App/DataPatcher.swift @@ -5,15 +5,16 @@ // Created by Kai Azim on 2025-09-07. // +import AppKit import Defaults -import Foundation import Scribe +@Loggable(style: .static) enum DataPatcher { static func run() { let initialPatches: Patches = Defaults[.patchesApplied] - runPatch(patch: .changeToAccentColorMode, initial: initialPatches) { + runPatchIfNeeded(patch: .changeToAccentColorMode, initialPatches: initialPatches) { // Migrate to accent color mode // We need to migrate `useSystemAccentColor` and `processWallpaper` over to `accentColorMode` let useSystemAccentColor: Bool = Defaults[.useSystemAccentColor] @@ -31,24 +32,50 @@ enum DataPatcher { Defaults.reset(.processWallpaper) } - runPatch(patch: .removeRevealedStashedWindows, initial: initialPatches) { + runPatchIfNeeded(patch: .removeRevealedStashedWindows, initialPatches: initialPatches) { Defaults.reset(.stashManagerRevealedWindows) } + + runPatchIfNeeded(patch: .changeTohideOnNoSelection, initialPatches: initialPatches) { + Defaults[.hideOnNoSelection] = Defaults[.hideUntilDirectionIsChosen] + Defaults.reset(.hideUntilDirectionIsChosen) + } } - private static func runPatch(patch: Patches, initial: Patches, with callback: () -> ()) { - if !initial.contains(patch) { + private static func runPatchIfNeeded(patch: Patches, initialPatches: Patches, with callback: () -> ()) { + if !initialPatches.contains(patch) { callback() Defaults[.patchesApplied].formUnion(patch) - Log.info("Ran patch \(patch)", category: .dataPatcher) + log.info("Ran patch \(patch)") } } struct Patches: OptionSet, Defaults.Serializable { let rawValue: Int + /// Changed accent color configuration from multiple bools to an enum static let changeToAccentColorMode = Self(rawValue: 1 << 0) + + /// Revealed statshed windows are no longer persisted across Loop lifecycles static let removeRevealedStashedWindows = Self(rawValue: 1 << 1) + + /// Key was renamed from `hideUntilDirectionIsChosen` to `hideOnNoSelection` with slightly different behavior + static let changeTohideOnNoSelection = Self(rawValue: 1 << 2) } } + +// MARK: - Migrated keys (private) + +// swiftformat:disable docComments +private extension Defaults.Keys { + // StashManager + static let stashManagerRevealedWindows = Key>("stashManagerRevealed", default: Set()) + + // AccentColorController + static let useSystemAccentColor = Key("useSystemAccentColor", default: true) + static let processWallpaper = Key("processWallpaper", default: false) + + // IndicatorService + static let hideUntilDirectionIsChosen = Key("hideUntilDirectionIsChosen", default: false) +} diff --git a/Loop/App/LaunchAtLoginManager.swift b/Loop/App/LaunchAtLoginManager.swift new file mode 100644 index 00000000..4323b915 --- /dev/null +++ b/Loop/App/LaunchAtLoginManager.swift @@ -0,0 +1,56 @@ +// +// LaunchAtLoginManager.swift +// Loop +// +// Created by Kai Azim on 2026-01-21. +// + +import Defaults +import Scribe +import ServiceManagement + +@Loggable +@MainActor +final class LaunchAtLoginManager { + static let shared = LaunchAtLoginManager() + + private var observationTask: Task<(), Never>? + + private init() { + self.observationTask = Task { [weak self] in + for await launchAtLogin in Defaults.updates(.launchAtLogin, initial: false) { + guard !Task.isCancelled, let self else { break } + await setLaunchAtLogin(launchAtLogin) + } + } + } + + deinit { + observationTask?.cancel() + } + + func start() { + Task { + await setLaunchAtLogin(Defaults[.launchAtLogin]) + } + } + + private func setLaunchAtLogin(_ enabled: Bool) async { + let currentlyEnabled = SMAppService.mainApp.status == .enabled + guard enabled != currentlyEnabled else { + return + } + + do { + if enabled { + try SMAppService.mainApp.register() + log.info("Registered login item") + } else { + try await SMAppService.mainApp.unregister() + log.info("Unregistered login item") + } + } catch { + log.error("Failed to \(enabled ? "register" : "unregister") login item: \(error.localizedDescription)") + } + } +} diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 33b1712a..da003c27 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -9,76 +9,94 @@ import Defaults import Scribe import SwiftUI -// MARK: - LoopManager - +@Loggable +@MainActor final class LoopManager { static let shared = LoopManager() private init() {} - // Size Adjustment - static var sidesToAdjust: Edge.Set? - static var lastTargetFrame: CGRect = .zero + /// Context for the current resize operation, tracking frame and edge adjustment state. + /// Initialized when Loop opens with a target window and screen. + private(set) var resizeContext: ResizeContext = .init() private let windowActionCache = WindowActionCache() - private let radialMenuController = RadialMenuController() - private let previewController = PreviewController() + private let indicatorService = WindowActionIndicatorService() private let updater = Updater.shared + private var accessibilityCheckerTask: Task<(), Never>? + + private(set) var isLoopActive: Bool = false + + private var lastLoopTime: Date = .now + private lazy var triggerKeyTimeoutTimer = TriggerKeyTimeoutTimer( - closeCallback: { [weak self] in self?.closeLoop(forceClose: $0) } + closeCallback: { [weak self] forceClose in + Task { await self?.closeLoop(forceClose: forceClose) } + } ) private(set) lazy var keybindTrigger = KeybindTrigger( windowActionCache: windowActionCache, - openCallback: { [weak self] in self?.openLoop(startingAction: $0) }, - closeCallback: { [weak self] in self?.closeLoop(forceClose: $0) }, - checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false } + openCallback: { [weak self] action in + Task { + await self?.openLoop(startingAction: action) + } + }, + closeCallback: { [weak self] forceClose in + Task { + await self?.closeLoop(forceClose: forceClose) + } + }, + checkIfLoopOpen: { [weak self] in + self?.isLoopActive ?? false + } ) private(set) lazy var middleClickTrigger = MiddleClickTrigger( - openCallback: { [weak self] in self?.openLoop(startingAction: $0) }, - closeCallback: { [weak self] in self?.closeLoop(forceClose: $0) }, + openCallback: { [weak self] action in + Task { + await self?.openLoop(startingAction: action) + } + }, + closeCallback: { [weak self] forceClose in + Task { + await self?.closeLoop(forceClose: forceClose) + } + }, checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false } ) private(set) lazy var mouseInteractionObserver = MouseInteractionObserver( windowActionCache: windowActionCache, changeAction: { [weak self] newAction in - /// If the mouse moved, that means that the keybind trigger should no longer passthrough special events such as the emoji key. - self?.keybindTrigger.canPassthroughNextSpecialEvent = false - self?.changeAction(newAction, canAdvanceCycle: false) + Task { + // If the mouse moved, that means that the keybind trigger should no longer passthrough special events such as the emoji key. + self?.keybindTrigger.canPassthroughNextSpecialEvent = false + await self?.changeAction(newAction, canAdvanceCycle: false) + } }, selectNextCycleItem: { [weak self] in - if let parentCycleAction = self?.parentCycleAction { - self?.changeAction(parentCycleAction, disableHapticFeedback: true) + Task { + if let parent = self?.resizeContext.parentAction { + await self?.changeAction(parent, disableHapticFeedback: true) + } } }, - canSelectNextCycleitem: { [weak self] in self?.parentCycleAction != nil }, + canSelectNextCycleitem: { [weak self] in + self?.resizeContext.parentAction != nil + }, checkIfLoopOpen: { [weak self] in self?.isLoopActive ?? false } ) - private var accessibilityCheckerTask: Task<(), Never>? - - private(set) var isLoopActive: Bool = false - private var targetWindow: Window? - private var screenToResizeOn: NSScreen? - var isShiftKeyPressed: Bool = false - - private var currentAction: WindowAction = .init(.noSelection) - private var parentCycleAction: WindowAction? - private(set) var initialMousePosition: CGPoint = .zero - - private var lastLoopTime: Date = .now - func start() { accessibilityCheckerTask = Task(priority: .background) { [weak self] in - for await status in await AccessibilityManager.shared.stream(initial: true) { + for await status in AccessibilityManager.shared.stream(initial: true) { guard let self, !Task.isCancelled else { return } if status { - keybindTrigger.start() + await keybindTrigger.start() middleClickTrigger.start() } else { keybindTrigger.stop() @@ -92,18 +110,18 @@ final class LoopManager { // MARK: - Opening/Closing Loop extension LoopManager { - private func openLoop(startingAction: WindowAction) { + private func openLoop(startingAction: WindowAction) async { guard AccessibilityManager.shared.isGranted else { return } guard !isLoopActive else { - /// If using Karabiner-Elements, TriggerKeybindObserver may call openLoop twice, as key events arrive in quick succession. - /// This happens because Karabiner-Elements sends modifier keys and other keys as separate, rapid events. - /// As a result, Loop might be opened before the full keybind is pressed. - /// In these cases, we can simply update the action instead of reopening the Loop. - if startingAction.direction != .noSelection { - changeAction(startingAction, disableHapticFeedback: true) + // If using Karabiner-Elements, TriggerKeybindObserver may call openLoop twice, as key events arrive in quick succession. + // This happens because Karabiner-Elements sends modifier keys and other keys as separate, rapid events. + // As a result, Loop might be opened before the full keybind is pressed. + // In these cases, we can simply update the action instead of reopening the Loop. + if startingAction.direction != .noSelection { // Can switch to .noAction still! + await changeAction(startingAction, disableHapticFeedback: true) } return @@ -118,68 +136,57 @@ extension LoopManager { return } - Log.info("Opening Loop with starting action: \(startingAction.description) and target window: \(window?.description ?? "(none)")", category: .loopManager) + log.info("Opening Loop with starting action: \(startingAction.description) and target window: \(window?.description ?? "(none)")") // Refresh accent colors in case user has enabled the wallpaper processor Task { await AccentColorController.shared.refresh() } - currentAction = .init(.noSelection) - targetWindow = window - parentCycleAction = nil - initialMousePosition = NSEvent.mouseLocation - screenToResizeOn = nil // Screen to resize on will be determined by the first action. - isShiftKeyPressed = false - - if !Defaults[.disableCursorInteraction] { - mouseInteractionObserver.start(initialMousePosition: initialMousePosition) + let initialFrame: CGRect = if let window { + // In case of a stashed window, use the revealed frame instead to prevent issue with frame calculation later. + StashManager.shared.getRevealedFrameForStashedWindow( + id: window.cgWindowID + ) ?? window.frame + } else { + .zero } - if !Defaults[.hideUntilDirectionIsChosen] { - openWindows(startingAction: startingAction, window: window) - } + resizeContext = ResizeContext( + window: window, + initialFrame: initialFrame, + initialMousePosition: NSEvent.mouseLocation + ) - if let window = targetWindow { - // In case of a stashed window, use the revealed frame instead to prevent issue with frame calculation later. - if let frame = StashManager.shared.getRevealedFrameForStashedWindow(id: window.cgWindowID) { - LoopManager.lastTargetFrame = frame - } else { - LoopManager.lastTargetFrame = window.frame - } + if !Defaults[.disableCursorInteraction] { + mouseInteractionObserver.start(initialMousePosition: resizeContext.initialMousePosition) } + indicatorService.openAndUpdate(context: resizeContext) + isLoopActive = true - changeAction(startingAction, disableHapticFeedback: true) + await changeAction(startingAction, disableHapticFeedback: true) triggerKeyTimeoutTimer.start() } - private func closeLoop(forceClose: Bool) { + private func closeLoop(forceClose: Bool) async { guard isLoopActive == true else { return } - Log.info("Closing Loop (force closed: \(forceClose))", category: .loopManager) + log.info("Closing Loop (force closed: \(forceClose))") - closeWindows() + indicatorService.closeAll() isLoopActive = false triggerKeyTimeoutTimer.cancel() mouseInteractionObserver.stop() // Handle normal actions with a target window - if forceClose == false, - let targetWindow, - let screenToResizeOn, - !currentAction.direction.willFocusWindow { + if !forceClose { // If the preview was disabled, the window will already be in the specified action's frame. // So only resize the window if the preview is enabled. if Defaults[.previewVisibility] { - WindowEngine.resize( - targetWindow, - to: currentAction, - on: screenToResizeOn - ) { - LoopManager.sidesToAdjust = nil - LoopManager.lastTargetFrame = .zero + Task { + _ = try? await WindowActionEngine.shared.apply(context: resizeContext) } } @@ -189,34 +196,11 @@ extension LoopManager { } Task { - if await updater.shouldAutoPresentUpdateWindow { + if updater.shouldAutoPresentUpdateWindow { await updater.showUpdateWindowIfEligible() } } } - - private func openWindows(startingAction: WindowAction, window: Window?) { - if Defaults[.previewVisibility], let screenToResizeOn { - previewController.open( - screen: screenToResizeOn, - window: window, - startingAction: startingAction - ) - } - - if Defaults[.radialMenuVisibility] { - radialMenuController.open( - position: initialMousePosition, - window: window, - startingAction: startingAction - ) - } - } - - private func closeWindows() { - radialMenuController.close() - previewController.close() - } } // MARK: - Changing Actions @@ -233,19 +217,20 @@ extension LoopManager { triggeredFromScreenChange: Bool = false, disableHapticFeedback: Bool = false, canAdvanceCycle: Bool = true - ) { + ) async { guard isLoopActive, - currentAction.id != newAction.id || newAction.canRepeat, - let currentScreen = screenToResizeOn ?? resolveAndStoreTargetScreen( + resizeContext.action.id != newAction.id || newAction.canRepeat, + let currentScreen = resizeContext.screen ?? resolveAndStoreTargetScreen( action: newAction, - window: targetWindow + window: resizeContext.window ) else { return } - var newAction = newAction + var newAction: WindowAction = newAction + var newParentAction: WindowAction? = nil triggerKeyTimeoutTimer.cancel() triggerKeyTimeoutTimer.start() @@ -255,20 +240,20 @@ extension LoopManager { } if newAction.direction == .cycle { - parentCycleAction = newAction + newParentAction = newAction // The ability to advance a cycle is only available when the action is triggered via a keybind or a left click on the mouse. // This should be set to false when the mouse is moved to prevent rapid cycling. if canAdvanceCycle { newAction = getNextCycleAction(newAction) } else { - if let cycle = newAction.cycle, !cycle.contains(currentAction) { + if let cycle = newAction.cycle, !cycle.contains(resizeContext.action) { newAction = cycle.first ?? .init(.noAction) } else { - newAction = currentAction + newAction = resizeContext.action } - if newAction == currentAction { + if newAction == resizeContext.action { return } } @@ -282,7 +267,7 @@ extension LoopManager { } } else { // By removing the parent cycle action, a left click will not advance the user's previously set cycle. - parentCycleAction = nil + newParentAction = nil } if newAction.direction.willChangeScreen { @@ -318,20 +303,22 @@ extension LoopManager { newScreen = bottomScreen } - if currentAction.direction == .noSelection || currentAction.willManipulateExistingWindowFrame { - if let targetWindow { + // If the current action is either `.noAction`/`.noSelection`, or `.smaller`/`.larger` etc, + // then we will preserve the window's proportional frame relative to the current screen on the new screen. + if resizeContext.action.direction.isNoOp || resizeContext.action.willManipulateExistingWindowFrame { + if let targetWindow = resizeContext.window { let screenSwitchingCustomActionName = "autogenerated_screen_switching_action" if let lastAction = WindowRecords.getCurrentAction(for: targetWindow), lastAction.getName() != screenSwitchingCustomActionName, !lastAction.forceProportionalFrameOnScreenChange { - currentAction = lastAction + resizeContext.setAction(to: lastAction, parent: nil) } else { let currentFrame = targetWindow.frame - let adjustedBounds = PaddingSettings - .configuredPadding(for: currentScreen) - .apply(onScreenFrame: currentScreen.safeScreenFrame) + let adjustedBounds = PaddingConfiguration + .getConfiguredPadding(for: currentScreen) + .applyToBounds(currentScreen.cgSafeScreenFrame) let proportionalSize = CGRect( x: (currentFrame.minX - adjustedBounds.minX) / adjustedBounds.width, @@ -340,51 +327,46 @@ extension LoopManager { height: currentFrame.height / adjustedBounds.height ) - currentAction = .init( - .custom, - keybind: [], - name: screenSwitchingCustomActionName, - unit: .percentage, - width: proportionalSize.width * 100, - height: proportionalSize.height * 100, - xPoint: proportionalSize.minX * 100, - yPoint: proportionalSize.minY * 100, - positionMode: .coordinates, - sizeMode: .custom + resizeContext.setAction( + to: .init( + .custom, + keybind: [], + name: screenSwitchingCustomActionName, + unit: .percentage, + width: proportionalSize.width * 100, + height: proportionalSize.height * 100, + xPoint: proportionalSize.minX * 100, + yPoint: proportionalSize.minY * 100, + positionMode: .coordinates, + sizeMode: .custom + ), + parent: nil ) } } else { - currentAction = .init(.center) + resizeContext.setAction(to: .init(.center), parent: nil) } } - screenToResizeOn = newScreen - previewController.setScreen(to: newScreen) + resizeContext.setScreen(to: newScreen) + indicatorService.openAndUpdate(context: resizeContext) - // This is only needed because if preview window is moved - // onto a new screen, it needs to receive a window action - previewController.setAction(to: currentAction) - radialMenuController.setAction(to: currentAction, parent: parentCycleAction) - - if let parentCycleAction { - currentAction = newAction - changeAction(parentCycleAction, triggeredFromScreenChange: true) + if let parent = newParentAction { + resizeContext.setAction(to: newAction, parent: newParentAction) + await changeAction(parent, triggeredFromScreenChange: true) } else { - if let window = targetWindow, - !Defaults[.previewVisibility] { + if !Defaults[.previewVisibility] { if !disableHapticFeedback { performHapticFeedback() } - WindowEngine.resize( - window, - to: currentAction, - on: newScreen - ) + Task { + _ = try await WindowActionEngine.shared.apply(context: resizeContext) + } } } - Log.info("Screen changed: \(newScreen.localizedName)", category: .loopManager) + log.info("Screen changed: \(newScreen.localizedName)") return } @@ -393,53 +375,27 @@ extension LoopManager { performHapticFeedback() } - if newAction != currentAction || newAction.canRepeat { - currentAction = newAction - - if Defaults[.hideUntilDirectionIsChosen] { - openWindows(startingAction: newAction, window: targetWindow) - } - - Task { @MainActor in - previewController.setAction(to: newAction) - radialMenuController.setAction(to: newAction, parent: parentCycleAction) + if newAction != resizeContext.action || newAction.canRepeat { + resizeContext.setAction(to: newAction, parent: newParentAction) + indicatorService.openAndUpdate(context: resizeContext) - if !Defaults[.previewVisibility], let screenToResizeOn, let targetWindow { - WindowEngine.resize( - targetWindow, - to: newAction, - on: screenToResizeOn - ) + Task { + if !Defaults[.previewVisibility] { + _ = try await WindowActionEngine.shared.apply(context: resizeContext) } // If the action is to focus a window in a specific direction, find and activate that window // This can work even without a current window (navigates from screen center) if newAction.direction.willFocusWindow { - var newTargetWindow: Window? + let result = try await WindowActionEngine.shared.apply(context: resizeContext) - if newAction.direction == .focusNextInStack, - let newWindow = WindowUtility.focusNextWindowInStack(from: targetWindow) { - newTargetWindow = newWindow - } - - if let focusDirection = newAction.direction.focusDirection, - let newWindow = WindowUtility.focusWindow(from: targetWindow, direction: focusDirection) { - newTargetWindow = newWindow - } - - if let newTargetWindow { - targetWindow = newTargetWindow - previewController.setWindow(to: newTargetWindow) - radialMenuController.setWindow(to: newTargetWindow) - - // If the previous window was nil, then the preview may have not opened. - // So open them here just in case. - openWindows(startingAction: newAction, window: newTargetWindow) + if let newTargetWindow = result.newTargetWindow { + resizeContext.setWindow(to: newTargetWindow) } } } - Log.info("Window action changed: \(newAction.description)", category: .loopManager) + log.info("Window action changed: \(newAction.description)") } } @@ -456,23 +412,23 @@ extension LoopManager { && Defaults[.triggerKey].contains(.kVK_Shift) == false && Defaults[.cycleBackwardsOnShiftPressed] - let shouldCycleBackwards = allowReverseCycle && isShiftKeyPressed + let shouldCycleBackwards = allowReverseCycle && keybindTrigger.effectiveEventFlags.contains(.maskShift) var currentIndex: Int? = nil if Defaults[.cycleModeRestartEnabled], - currentAction.direction == .noSelection || !currentCycle.contains(currentAction) { + resizeContext.action.direction == .noSelection || !currentCycle.contains(resizeContext.action) { return currentCycle[0] } // If the current action is noSelection, we can preserve the index from the last action. // This would initially be done by reading the window's records, then would continue by finding the next index from the currentAction. - if currentAction.direction == .noSelection, - !currentCycle.contains(currentAction), - let window = targetWindow, + if resizeContext.action.direction == .noSelection, + !currentCycle.contains(resizeContext.action), + let window = resizeContext.window, let latestRecord = WindowRecords.getCurrentAction(for: window) { currentIndex = currentCycle.firstIndex(of: latestRecord) } else { - currentIndex = currentCycle.firstIndex(of: currentAction) + currentIndex = currentCycle.firstIndex(of: resizeContext.action) } guard var nextIndex = currentIndex else { @@ -495,10 +451,7 @@ extension LoopManager { private func performHapticFeedback() { if Defaults[.hapticFeedback] { - NSHapticFeedbackManager.defaultPerformer.perform( - NSHapticFeedbackManager.FeedbackPattern.alignment, - performanceTime: NSHapticFeedbackManager.PerformanceTime.now - ) + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) } } @@ -519,10 +472,12 @@ extension LoopManager { targetScreen = screen } - screenToResizeOn = targetScreen + resizeContext.setScreen(to: targetScreen) - // If a screen was previously not selected, then the preview needs to be opened. - openWindows(startingAction: action, window: targetWindow) + if !resizeContext.action.direction.isNoOp { + // If a screen was previously not selected, then the preview needs to be opened. + indicatorService.openAndUpdate(context: resizeContext) + } return targetScreen } diff --git a/Loop/Core/Observers/KeybindTrigger.swift b/Loop/Core/Observers/KeybindTrigger.swift index b19dc8fd..5c2bd610 100644 --- a/Loop/Core/Observers/KeybindTrigger.swift +++ b/Loop/Core/Observers/KeybindTrigger.swift @@ -7,9 +7,11 @@ import Cocoa import Defaults +import Scribe /// Monitors `keyDown`, `keyUp`, and `flagsChanged` events using an ActiveEventMonitor, invoking Loop’s open and close callbacks as needed. /// Additionally, this class manages keybind action retrieval and updates Loop based on those actions. +@Loggable final class KeybindTrigger { // Parameters private let windowActionCache: WindowActionCache @@ -19,17 +21,17 @@ final class KeybindTrigger { // State-tracking private var pressedKeys: Set = [] - private var previousEventFlags: CGEventFlags = [] + private(set) var effectiveEventFlags: CGEventFlags = [] private var eventMonitor: ActiveEventMonitor? private var systemKeybindCache: Set> = [] private var keybindCacheUpdatedAt: ContinuousClock.Instant? private let keybindCacheLifetime: ContinuousClock.Duration = .seconds(30) - // Special events only contain the globe key, as it can also be used as an emoji key. + /// Special events only contain the globe key, as it can also be used as an emoji key. private let specialEventKeys: [CGKeyCode] = [.kVK_Globe_Emoji] - // Will be set to `false` if the mouse has been moved by LoopManager. + /// Will be set to `false` if the mouse has been moved by LoopManager. var canPassthroughNextSpecialEvent = true private var useTriggerDelay: Bool { Defaults[.triggerDelay] > 0.1 } @@ -69,8 +71,8 @@ final class KeybindTrigger { self.checkIfLoopOpen = checkIfLoopOpen } - func start() { - guard AccessibilityManager.shared.isGranted else { + func start() async { + guard await AccessibilityManager.shared.isGranted else { return } @@ -82,15 +84,13 @@ final class KeybindTrigger { let keyCode = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) .baseKey(flags: .init(rawValue: UInt(event.flags.rawValue))) - LoopManager.shared.isShiftKeyPressed = event.flags.contains(.maskShift) - var filteredFlags = event.flags - if keyCode.isFnSpecialKey, !previousEventFlags.contains(.maskSecondaryFn) { + if keyCode.isFnSpecialKey, !effectiveEventFlags.contains(.maskSecondaryFn) { filteredFlags.remove(.maskSecondaryFn) } let isLoopOpen = checkIfLoopOpen() - previousEventFlags = filteredFlags + effectiveEventFlags = filteredFlags if event.type == .keyUp { pressedKeys.remove(keyCode) @@ -114,6 +114,7 @@ final class KeybindTrigger { ) if result == .consume { + log.debug("Blocked event") return .ignore } @@ -173,7 +174,7 @@ final class KeybindTrigger { if type != .keyDown, !containsTrigger { closeLoop(forceClose: false) - return .consume + return .forward } } @@ -186,8 +187,8 @@ final class KeybindTrigger { openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) } - /// Only consume the event if the last command actually opened Loop. - /// The main reason Loop *wouldn't* open after an `openLoop` call would be because the user has enabled a trigger delay. + // Only consume the event if the last command actually opened Loop. + // The main reason Loop *wouldn't* open after an `openLoop` call would be because the user has enabled a trigger delay. return isLoopOpen ? .consume : .opening } @@ -237,6 +238,7 @@ final class KeybindTrigger { private func closeLoop(forceClose: Bool) { triggerDelayTimer.cancel() closeCallback(forceClose) + pressedKeys = [] } private func startTriggerDelayTimer( diff --git a/Loop/Core/Observers/MiddleClickTrigger.swift b/Loop/Core/Observers/MiddleClickTrigger.swift index a7812c9b..1675624c 100644 --- a/Loop/Core/Observers/MiddleClickTrigger.swift +++ b/Loop/Core/Observers/MiddleClickTrigger.swift @@ -15,7 +15,6 @@ final class MiddleClickTrigger { private let closeCallback: (Bool) -> () private let checkIfLoopOpen: () -> Bool - // State-tracking private var monitor: PassiveEventMonitor? // Defaults diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index a507d661..1aeaa307 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -9,6 +9,7 @@ import Defaults import Scribe import SwiftUI +@Loggable final class MouseInteractionObserver { private static let directionalActionDistance: CGFloat = 50 private static let noActionDistance: CGFloat = 10 @@ -56,7 +57,7 @@ final class MouseInteractionObserver { screenBounds = NSScreen.screens.first(where: { $0.frame.contains(initialMousePosition) })?.frame if let screenBounds { - /// If the current mouse position isn't sufficient for accessing direcitonal actions due to being close to the screen's edge, then enable `shouldAccountForAbsoluteMousePosition` + // If the current mouse position isn't sufficient for accessing direcitonal actions due to being close to the screen's edge, then enable `shouldAccountForAbsoluteMousePosition` let closeToMinX = abs(initialMousePosition.x - screenBounds.minX) < Self.directionalActionDistance let closeToMaxX = abs(initialMousePosition.x - screenBounds.maxX) < Self.directionalActionDistance let closeToMinY = abs(initialMousePosition.y - screenBounds.minY) < Self.directionalActionDistance @@ -87,7 +88,7 @@ final class MouseInteractionObserver { leftClickMonitor.start() self.leftClickMonitor = leftClickMonitor - Log.info("Started with initial mouse position: \(latestMousePosition.debugDescription)", category: .mouseInteractionObserver) + log.info("Started with initial mouse position: \(latestMousePosition.debugDescription)") } func stop() { @@ -105,47 +106,47 @@ final class MouseInteractionObserver { initialMousePosition = .zero latestMousePosition = .zero - Log.success("Stopped, all stored states cleared.", category: .mouseInteractionObserver) + log.success("Stopped, all stored states cleared.") } private func processNewMouseLocation(_ event: CGEvent) { guard checkIfLoopOpen() else { return } - let currentMousePosition = computeLatestMousePosition(event) - let angleToMouse = initialMousePosition.angle(to: currentMousePosition) + .radians(.pi / 2) - let distanceToMouse = initialMousePosition.distance(to: currentMousePosition) + Task { + let currentMousePosition = computeLatestMousePosition(event) + let angleToMouse = initialMousePosition.angle(to: currentMousePosition) + .radians(.pi / 2) + let distanceToMouse = initialMousePosition.distance(to: currentMousePosition) - // Return if the mouse didn't move - guard - angleToMouse != previousAngleToMouse || - distanceToMouse != previousDistanceToMouse - else { - return - } + // Return if the mouse didn't move + guard + angleToMouse != previousAngleToMouse || + distanceToMouse != previousDistanceToMouse + else { + return + } - // Get angle & distance to mouse - previousAngleToMouse = angleToMouse - previousDistanceToMouse = distanceToMouse + // Get angle & distance to mouse + previousAngleToMouse = angleToMouse + previousDistanceToMouse = distanceToMouse - var newAction: RadialMenuAction? = nil + var newAction: RadialMenuAction? = nil - // If mouse over 50 points away, select half or quarter positions - if distanceToMouse > Self.directionalActionDistance - Defaults[.radialMenuThickness] { - guard radialMenuActions.count > 1 else { - newAction = radialMenuActions.first - return - } + // If mouse over 50 points away, select half or quarter positions + if distanceToMouse > Self.directionalActionDistance - Defaults[.radialMenuThickness] { + guard radialMenuActions.count > 1 else { + newAction = radialMenuActions.first + return + } - let actions = radialMenuActions.dropLast() - let actionAngleSpan = 360.0 / CGFloat(actions.count) - let halfAngleSpan = actionAngleSpan / 2.0 - let index = Int((angleToMouse.normalized().degrees + halfAngleSpan) / actionAngleSpan) % actions.count - newAction = actions[index] - } else if distanceToMouse > Self.noActionDistance { - newAction = radialMenuActions.last - } + let actions = radialMenuActions.dropLast() + let actionAngleSpan = 360.0 / CGFloat(actions.count) + let halfAngleSpan = actionAngleSpan / 2.0 + let index = Int((angleToMouse.normalized().degrees + halfAngleSpan) / actionAngleSpan) % actions.count + newAction = actions[index] + } else if distanceToMouse > Self.noActionDistance { + newAction = radialMenuActions.last + } - Task { @MainActor in switch newAction?.type { case let .custom(windowAction): changeAction(windowAction) @@ -209,8 +210,8 @@ final class MouseInteractionObserver { } private func activateNextCycleAction(_ event: CGEvent) -> ActiveEventMonitor.EventHandling { - /// Ensure that the source originates from the HID state ID. - /// Otherwise, this event was likely sent from Loop to focus the frontmost click (see `Window.focus` which sends a `SLSEvent` to the window) + // Ensure that the source originates from the HID state ID. + // Otherwise, this event was likely sent from Loop to focus the frontmost click (see `Window.focus` which sends a `SLSEvent` to the window) let sourceID = CGEventSourceStateID(rawValue: Int32(event.getIntegerValueField(.eventSourceStateID))) guard sourceID == .hidSystemState else { return .forward diff --git a/Loop/Core/SystemWindowManager.swift b/Loop/Core/SystemWindowManager.swift index 74a8b763..993e9dd7 100644 --- a/Loop/Core/SystemWindowManager.swift +++ b/Loop/Core/SystemWindowManager.swift @@ -30,7 +30,7 @@ final class SystemWindowManager { // MARK: - Move & Resize - // This is a direct mapping of the menu items in the "Move & Resize" menu + /// This is a direct mapping of the menu items in the "Move & Resize" menu @available(macOS 15, *) enum MoveAndResize: String { // General diff --git a/Loop/Core/URLCommandHandler.swift b/Loop/Core/URLCommandHandler.swift index 58d19743..81fb00c5 100644 --- a/Loop/Core/URLCommandHandler.swift +++ b/Loop/Core/URLCommandHandler.swift @@ -87,6 +87,7 @@ import Scribe import SwiftUI /// Handles URL scheme commands for the Loop application +@Loggable final class URLCommandHandler { // MARK: - Types @@ -142,7 +143,7 @@ final class URLCommandHandler { cleanMessage.hasPrefix("Found") || cleanMessage.hasPrefix("Window:") || (cleanMessage.hasPrefix("Processing") && !cleanMessage.contains("command:")) { - Log.info(cleanMessage, category: .urlHandler) + log.info(cleanMessage) return } @@ -150,9 +151,9 @@ final class URLCommandHandler { if currentCommand?.contains("/list") == true { outputBuffer.append(output) } else { - Log.info("\(output)", category: .urlHandler) + log.info("\(output)") } - Log.info(cleanMessage, category: .urlHandler) + log.info(cleanMessage) } /// Writes a titled list of items to output @@ -171,8 +172,8 @@ final class URLCommandHandler { outputBuffer.append(title) outputBuffer.append(contentsOf: formattedItems) } else { - Log.info("\n\(title)", category: .urlHandler) - formattedItems.forEach { Log.info("\($0)", category: .urlHandler) } + log.info("\n\(title)") + formattedItems.forEach { log.info("\($0)") } } } @@ -199,19 +200,21 @@ final class URLCommandHandler { // Schedule file deletion after a delay // We use a longer delay (60s) to ensure the user has time to read the content - DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [tempFile] in + Task { + try? await Task.sleep(for: .seconds(60)) + do { try FileManager.default.removeItem(at: tempFile) - Log.info("Cleaned up temporary file: \(tempFile.lastPathComponent)", category: .urlHandler) + log.info("Cleaned up temporary file: \(tempFile.lastPathComponent)") } catch { - Log.error("Failed to clean up temporary file: \(error.localizedDescription)", category: .urlHandler) + log.error("Failed to clean up temporary file: \(error.localizedDescription)") } } } catch { - Log.error("Failed to write output: \(error.localizedDescription)", category: .urlHandler) + log.error("Failed to write output: \(error.localizedDescription)") // Fallback to direct console output if file operations fail - Log.info("\(outputBuffer.joined(separator: "\n"))", category: .urlHandler) + log.info("\(outputBuffer.joined(separator: "\n"))") } outputBuffer.removeAll() @@ -252,8 +255,8 @@ final class URLCommandHandler { /// - command: The command to process /// - parameters: Array of command parameters private func processCommand(_ command: Command, _ parameters: [String]) { - Log.info(command.rawValue, category: .urlHandler) - Log.info(parameters.description, category: .urlHandler) + log.info(command.rawValue) + log.info(parameters.description) switch command { case .direction: handleDirectionCommand(parameters) @@ -483,7 +486,13 @@ final class URLCommandHandler { writeToOutput("[URLHandler] Executing keybind: \(keybind.name ?? "unnamed")") if let window = WindowUtility.userDefinedTargetWindow(), let screen = NSScreen.main { - WindowEngine.resize(window, to: keybind, on: screen) + Task { + _ = try await WindowActionEngine.shared.apply( + keybind, + window: window, + screen: screen + ) + } } } else { writeToOutput("[URLHandler] Keybind not found: \(keybindName)") @@ -648,10 +657,16 @@ final class URLCommandHandler { app.activate(options: .activateIgnoringOtherApps) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.writeToOutput("[URLHandler] Executing resize operation") - WindowEngine.resize(window, to: action, on: screen) - self?.writeToOutput("[URLHandler] New window frame: \(window.frame)") + Task { + try? await Task.sleep(for: .seconds(0.1)) + + writeToOutput("[URLHandler] Executing resize operation") + _ = try await WindowActionEngine.shared.apply( + action, + window: window, + screen: screen + ) + writeToOutput("[URLHandler] New window frame: \(window.frame)") } } @@ -662,8 +677,12 @@ final class URLCommandHandler { ScreenUtility.nextScreen(from: currentScreen) : ScreenUtility.previousScreen(from: currentScreen) { writeToOutput("[URLHandler] Moving window to screen: \(targetScreen.localizedName)") - DispatchQueue.main.async { - WindowEngine.resize(window, to: .init(direction), on: targetScreen) + Task { + _ = try await WindowActionEngine.shared.apply( + .init(direction), + window: window, + screen: targetScreen + ) } } else { writeToOutput("[URLHandler] Failed to find target screen") diff --git a/Loop/Core/WindowDragManager.swift b/Loop/Core/WindowDragManager.swift index 37b7e676..0b5e8fdc 100644 --- a/Loop/Core/WindowDragManager.swift +++ b/Loop/Core/WindowDragManager.swift @@ -9,19 +9,16 @@ import Defaults import Scribe import SwiftUI +@Loggable @MainActor final class WindowDragManager { static let shared = WindowDragManager() private init() {} - private var initialMousePosition: CGPoint? - private var didPassDragDistanceThreshold: Bool = false - private var dragDistanceThreshold: CGFloat = 5 - - private var draggingWindow: Window? + private var resizeContext: ResizeContext? private var initialWindowFrame: CGRect? - private var direction: WindowDirection = .noAction - // Avoid repeated window resolution attempts during a non-window drag (e.g. in games). + + /// This is to avoid repeated window resolution attempts during a non-window drag (e.g. in games). private var didFailToResolveDraggedWindow: Bool = false private let previewController = PreviewController() @@ -36,7 +33,7 @@ final class WindowDragManager { NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) } - // Avoid running global drag logic unless a feature actually depends on it. + /// This is to avoid running global drag logic unless a feature actually depends on it. private var shouldMonitorDragActions: Bool { Defaults[.windowSnapping] || Defaults[.restoreWindowFrameOnDrag] || @@ -91,25 +88,14 @@ final class WindowDragManager { } Task { - guard let initialMousePosition else { - initialMousePosition = currentMousePosition - return - } - - if !didPassDragDistanceThreshold { - didPassDragDistanceThreshold = currentMousePosition.distance(to: initialMousePosition) > dragDistanceThreshold - - guard didPassDragDistanceThreshold else { - return - } - } - // Process window (only ONCE during a window drag) - if draggingWindow == nil, !didFailToResolveDraggedWindow { + if resizeContext == nil, !didFailToResolveDraggedWindow { setCurrentDraggingWindow() } - if let window = draggingWindow, let initialFrame = initialWindowFrame, hasWindowResized(window.frame, initialFrame) { + if let window = resizeContext?.window, + let initialFrame = initialWindowFrame, + hasWindowResized(window.frame, initialFrame) { if hasWindowMoved(window.frame, initialFrame) { if Defaults[.restoreWindowFrameOnDrag] { restoreInitialWindowSize(window) @@ -129,7 +115,7 @@ final class WindowDragManager { } } - StashManager.shared.onWindowDragged(window.cgWindowID) + StashManager.shared.onWindowManipulated(window.cgWindowID) WindowRecords.eraseRecords(for: window) } } @@ -143,14 +129,18 @@ final class WindowDragManager { Task { previewController.close() - if let window = draggingWindow, - let screen = NSScreen.screenWithMouse, + if let context = resizeContext, + !context.action.direction.isNoOp, + let window = context.window, let initialFrame = initialWindowFrame, hasWindowMoved(window.frame, initialFrame) { - WindowEngine.resize(window, to: .init(direction), on: screen) + do { + _ = try await WindowActionEngine.shared.apply(context: context) + } catch { + log.error("Failed to snap window: \(error.localizedDescription)") + } } - draggingWindow = nil resetDragState() } } @@ -165,27 +155,27 @@ final class WindowDragManager { determineDraggedWindowTask = nil } - guard let draggingWindow = try? WindowUtility.windowAtPosition(currentMousePosition), - !draggingWindow.isAppExcluded + guard let window = WindowUtility.windowAtPosition(currentMousePosition), + !window.isAppExcluded else { didFailToResolveDraggedWindow = true return } - self.draggingWindow = draggingWindow - initialWindowFrame = draggingWindow.frame - didFailToResolveDraggedWindow = false + initialWindowFrame = window.frame + resizeContext = ResizeContext( + window: window, + initialMousePosition: currentMousePosition + ) - Log.info("Determined window being dragged: \(draggingWindow.description)", category: .windowDragManager) + log.info("Determined window being dragged: \(window.description)") } } private func resetDragState() { - initialMousePosition = nil - didPassDragDistanceThreshold = false + resizeContext = nil didFailToResolveDraggedWindow = false initialWindowFrame = nil - direction = .noAction determineDraggedWindowTask?.cancel() determineDraggedWindowTask = nil } @@ -245,8 +235,6 @@ final class WindowDragManager { let mainScreen = NSScreen.screens[0] let screenFrame = screen.frame.flipY(screen: mainScreen) - previewController.setScreen(to: screen) - let inset = Defaults[.snapThreshold] let topInset = max(screen.menubarHeight / 2, inset) var ignoredFrame = screenFrame @@ -256,7 +244,7 @@ final class WindowDragManager { ignoredFrame.origin.y += topInset ignoredFrame.size.height -= inset + topInset - let oldDirection = direction + let oldDirection = resizeContext?.action.direction ?? .noAction if !ignoredFrame.contains(currentMousePosition) { // Refresh accent colors in case user has enabled the wallpaper processor @@ -273,19 +261,23 @@ final class WindowDragManager { // Only update if direction actually changed if newDirection != oldDirection { - direction = newDirection + log.info("Window snapping direction changed: \(newDirection.debugDescription)") - Log.info("Window snapping direction changed: \(newDirection.debugDescription)", category: .windowDragManager) + resizeContext?.setScreen(to: screen) + resizeContext?.setAction(to: .init(newDirection), parent: nil) - previewController.open(screen: screen, window: draggingWindow, startingAction: nil) - previewController.setAction(to: WindowAction(newDirection)) + if let context = resizeContext { + previewController.open(context: context) + } + // Haptic feedback if newDirection != .noAction, Defaults[.hapticFeedback] { NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) } } - } else if !(oldDirection == .noAction || oldDirection == .noSelection) { - direction = .noAction + } else if !oldDirection.isNoOp { + // Only close if we were showing something + resizeContext?.setAction(to: .init(.noAction), parent: nil) previewController.close() } } diff --git a/Loop/Extensions/AXUIElement+Extensions.swift b/Loop/Extensions/AXUIElement+Extensions.swift index bce37c92..c88be88b 100644 --- a/Loop/Extensions/AXUIElement+Extensions.swift +++ b/Loop/Extensions/AXUIElement+Extensions.swift @@ -57,7 +57,7 @@ extension AXUIElement { return element } - // Only used when experimenting + /// Only used when experimenting :) func getAttributeNames() -> [String]? { var ref: CFArray? let error = AXUIElementCopyAttributeNames(self, &ref) diff --git a/Loop/Extensions/Bundle+Extensions.swift b/Loop/Extensions/Bundle+Extensions.swift index 9f74d42c..71bc3e9b 100644 --- a/Loop/Extensions/Bundle+Extensions.swift +++ b/Loop/Extensions/Bundle+Extensions.swift @@ -7,7 +7,6 @@ import Foundation -// Returns the current build number extension Bundle { var appName: String { getInfo("CFBundleName") ?? "⚠️" diff --git a/Loop/Extensions/CGGeometry+Extensions.swift b/Loop/Extensions/CGGeometry+Extensions.swift index 7d70a346..5e685a73 100644 --- a/Loop/Extensions/CGGeometry+Extensions.swift +++ b/Loop/Extensions/CGGeometry+Extensions.swift @@ -189,17 +189,6 @@ extension CGRect { return result } - /// Returns a new rectangle with integer values for the origin and size. - /// - Returns: A new rectangle with integer values for the origin and size. - func integerRect() -> CGRect { - CGRect( - x: floor(minX), - y: floor(minY), - width: floor(width), - height: floor(height) - ) - } - /// Returns true if the rectangle is finite, false otherwise. var isFinite: Bool { origin.x.isFinite && diff --git a/Loop/Extensions/CGKeyCode+Extensions.swift b/Loop/Extensions/CGKeyCode+Extensions.swift index 2400deac..2f30f98d 100644 --- a/Loop/Extensions/CGKeyCode+Extensions.swift +++ b/Loop/Extensions/CGKeyCode+Extensions.swift @@ -130,7 +130,7 @@ extension CGKeyCode { static let kVK_Globe_Emoji: CGKeyCode = 0xB3 - // ISO keyboards only + /// ISO keyboards only static let kVK_ISO_Section: CGKeyCode = 0x0A // JIS keyboards only @@ -144,7 +144,8 @@ extension CGKeyCode { // MARK: Base key conversion and computed properties extension CGKeyCode { - // Some keycodes seem to alter when a modifier key (ex. the globe key) is being pressed. + /// Some keycodes seem to alter when a modifier key (ex. the globe key) is being pressed. + /// This function attempts to try and restore the "original" key. func baseKey(flags: NSEvent.ModifierFlags) -> CGKeyCode { if self == .kVK_ANSI_KeypadEnter { return .kVK_Return @@ -184,9 +185,9 @@ extension CGKeyCode { } var isFnSpecialKey: Bool { - /// See: https://github.com/koekeishiya/skhd/issues/1 + // See: https://github.com/koekeishiya/skhd/issues/1 let specialKeys: Set = [ - .kVK_Delete, /// Usually `kVK_ForwardDelete`, but `baseKey(flags:)` converts that back into `kVK_Delete` if the fn key is also being pressed. + .kVK_Delete, // Usually `kVK_ForwardDelete`, but `baseKey(flags:)` converts that back into `kVK_Delete` if the fn key is also being pressed. .kVK_Help ] let allKeys = specialKeys @@ -214,7 +215,7 @@ extension CGKeyCode { // MARK: Stringification of keycodes extension CGKeyCode { - // From https://github.com/sindresorhus/KeyboardShortcuts/ but edited a bit + /// From https://github.com/sindresorhus/KeyboardShortcuts/ but edited a bit private static let keyToString: [CGKeyCode: String] = [ .kVK_Return: "↩", .kVK_Delete: "⌫", @@ -276,7 +277,7 @@ extension CGKeyCode { .kVK_ANSI_KeypadPlus: "+\u{20e3}" ] - // Make sure to use baseModifier before using this! + /// Make sure to use baseModifier before using this! private static let modifierToSystemImage: [CGKeyCode: String] = [ .kVK_Function: "globe", .kVK_Shift: "shift", @@ -293,7 +294,7 @@ extension CGKeyCode { } } - // Big thanks to https://github.com/sindresorhus/KeyboardShortcuts/ + /// Big thanks to https://github.com/sindresorhus/KeyboardShortcuts/ var humanReadable: String? { guard let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(), diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 1aa25fd9..6311028d 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -47,7 +47,7 @@ extension Defaults.Keys { static let suppressMissionControlOnTopDrag = Key("suppressMissionControlOnTopDrag", default: true, iCloud: true) static let restoreWindowFrameOnDrag = Key("restoreWindowFrameOnDrag", default: false, iCloud: true) static let enablePadding = Key("enablePadding", default: false, iCloud: true) - static let padding = Key("padding", default: .zero, iCloud: true) + static let padding = Key("padding", default: .zero, iCloud: true) static let useScreenWithCursor = Key("useScreenWithCursor", default: true, iCloud: true) static let moveCursorWithWindow = Key("moveCursorWithWindow", default: false, iCloud: true) static let resizeWindowUnderCursor = Key("resizeWindowUnderCursor", default: false, iCloud: true) @@ -74,19 +74,19 @@ extension Defaults.Keys { static let animateWindowResizes = Key("animateWindowResizes", default: false, iCloud: true) static let disableCursorInteraction = Key("disableCursorInteraction", default: false, iCloud: true) static let ignoreFullscreen = Key("ignoreFullscreen", default: false, iCloud: true) - static let hideUntilDirectionIsChosen = Key("hideUntilDirectionIsChosen", default: false, iCloud: true) + static let hideOnNoSelection = Key("hideOnNoSelection", default: false, iCloud: true) static let hapticFeedback = Defaults.Key("hapticFeedback", default: true, iCloud: true) static let enableRadialMenuCustomization = Defaults.Key("enableRadialMenuCustomization", default: false, iCloud: true) static let sizeIncrement = Key("sizeIncrement", default: 20, iCloud: true) - // Excluded apps + /// Excluded apps static let excludedApps = Key<[URL]>("excludedApps", default: [], iCloud: true) // About #if RELEASE static let includeDevelopmentVersions = Key("includeDevelopmentVersions", default: false, iCloud: true) #else - // Development versions should check for development updates by default. + /// Development versions should check for development updates by default. static let includeDevelopmentVersions = Key("includeDevelopmentVersions", default: true, iCloud: true) #endif static let automaticallyUpdate = Key("automaticallyUpdate", default: false, iCloud: true) @@ -119,10 +119,10 @@ extension Defaults.Keys { /// Reset with `defaults delete com.MrKai77.Loop previewStartingPosition` /// /// Available options: - /// - `screenCenter`: Center of the screen (default behavior) + /// - `screenCenter`: Center of the screen /// - `radialMenu`: Center of radial menu /// - `actionCenter`: Center of the selected action (e.g. for left half, it will grow from the center of that left half) - static let previewStartingPosition = Key("previewStartingPosition", default: .screenCenter, iCloud: true) + static let previewStartingPosition = Key("previewStartingPosition", default: .actionCenter, iCloud: true) /// Disable automatic updates with `defaults write com.MrKai77.Loop updatesEnabled -bool false` /// Reset with `defaults delete com.MrKai77.Loop updatesEnabled` @@ -134,25 +134,16 @@ extension Defaults.Keys { /// Reset with `defaults delete com.MrKai77.Loop triggerKeyTimeout` static let triggerKeyTimeout = Key("triggerKeyTimeout", default: 0, iCloud: true) - // Migrator + /// Migrator static let lastMigratorURL = Key("lastMigratorURL", default: nil) - // StashManager + /// StashManager static let stashManagerStashedWindows = Key<[CGWindowID: WindowAction]>("stashManagerStashed", default: [:]) - @available(*, deprecated, message: "Revealed stash windows are no longer tracked.") - static let stashManagerRevealedWindows = Key>("stashManagerRevealed", default: Set()) - // AccentColorController static let lastUsedAccentColor1 = Key("lastUsedAccentColor1", default: .black) static let lastUsedAccentColor2 = Key("lastUsedAccentColor2", default: .black) - @available(*, deprecated, renamed: "accentColorMode", message: "Use accentColorMode.system") - static let useSystemAccentColor = Key("useSystemAccentColor", default: true, iCloud: true) - - @available(*, deprecated, renamed: "accentColorMode", message: "Use accentColorMode.wallpaper") - static let processWallpaper = Key("processWallpaper", default: false, iCloud: true) - - // DataPatcher + /// DataPatcher static let patchesApplied = Key("patchesApplied", default: [], iCloud: true) } diff --git a/Loop/Extensions/LogCategory+Extensions.swift b/Loop/Extensions/LogCategory+Extensions.swift deleted file mode 100644 index 9b094f85..00000000 --- a/Loop/Extensions/LogCategory+Extensions.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// LogCategory+Extensions.swift -// Loop -// -// Created by Kai Azim on 2025-10-10. -// - -import Foundation -import Scribe - -/// Centralized Scribe categories used across Loop. -extension LogCategory { - // App lifecycle & core coordination - static let appDelegate = LogCategory("AppDelegate") - static let loopManager = LogCategory("LoopManager") - static let dataPatcher = LogCategory("DataPatcher") - static let urlHandler = LogCategory("URLHandler") - - // Settings & configuration surfaces - static let settingsWindowManager = LogCategory("SettingsWindowManager") - static let behaviorConfigurationView = LogCategory("BehaviorConfigurationView") - static let advancedConfigurationModel = LogCategory("AdvancedConfigurationModel") - static let pickerView = LogCategory("PickerView") - - // Appearance & theming - static let accentColorController = LogCategory("AccentColorController") - static let wallpaperProcessor = LogCategory("WallpaperProcessor") - static let iconManager = LogCategory("IconManager") - - // Window management - static let windowUtility = LogCategory("WindowUtility") - static let window = LogCategory("Window") - static let windowEngine = LogCategory("WindowEngine") - static let windowRecords = LogCategory("WindowRecords") - static let windowAction = LogCategory("WindowAction") - static let windowActionCache = LogCategory("WindowActionCache") - static let windowDragManager = LogCategory("WindowDragManager") - - // Window action indicators - static let radialMenuController = LogCategory("RadialMenuController") - static let previewController = LogCategory("PreviewController") - - // Stashing - static let stashManager = LogCategory("StashManager") - static let stashedWindowsStore = LogCategory("StashedWindowsStore") - static let stashedWindow = LogCategory("StashedWindow") - - // Event monitoring & input - static let localEventMonitor = LogCategory("LocalEventMonitor") - static let baseEventTapMonitor = LogCategory("BaseEventTapMonitor") - static let passiveEventMonitor = LogCategory("PassiveEventMonitor") - static let activeEventMonitor = LogCategory("ActiveEventMonitor") - static let mouseInteractionObserver = LogCategory("MouseInteractionObserver") - - // Updates & maintenance - static let updater = LogCategory("Updater") - static let migrator = LogCategory("Migrator") - - // Private APIs - static let skyLightToolBelt = LogCategory("SkyLightToolBelt") - static let skyLightSymbolLoader = LogCategory("SkyLightSymbolLoader") -} diff --git a/Loop/Extensions/NSScreen+Extensions.swift b/Loop/Extensions/NSScreen+Extensions.swift index 4959e666..91ad9d17 100644 --- a/Loop/Extensions/NSScreen+Extensions.swift +++ b/Loop/Extensions/NSScreen+Extensions.swift @@ -9,8 +9,6 @@ import Defaults import SwiftUI extension NSScreen { - // Return the CGDirectDisplayID - // Used in to help calculate the size a window needs to be resized to var displayID: CGDirectDisplayID? { let key = NSDeviceDescriptionKey("NSScreenNumber") return deviceDescription[key] as? CGDirectDisplayID @@ -30,36 +28,27 @@ extension NSScreen { static var screenWithMouse: NSScreen? { let mouseLocation = NSEvent.mouseLocation let screens = NSScreen.screens - let screenWithMouse = (screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }) - - return screenWithMouse + return screens.first { $0.frame.contains(mouseLocation) } } - var safeScreenFrame: CGRect { - guard - let displayID - else { - NSLog("Error: Failed to get NSScreen.displayID in NSScreen.safeScreenFrame") - return frame.flipY(screen: self) - } - - let screenFrame = CGDisplayBounds(displayID) - let visibleFrame = stageStripFreeFrame.flipY(screen: self) + var cgSafeScreenFrame: CGRect { + let cgbounds = displayBounds + let visibleFrame = safeScreenFrame.flipY(screen: NSScreen.screens[0]) // By setting safeScreenFrame to visibleFrame, we won't need to adjust its size. var safeScreenFrame = visibleFrame // By using visibleFrame, coordinates of multiple displays won't // work correctly, so we instead use screenFrame's origin. - safeScreenFrame.origin = screenFrame.origin + safeScreenFrame.origin = cgbounds.origin safeScreenFrame.origin.y += menubarHeight - safeScreenFrame.origin.x -= screenFrame.minX - visibleFrame.minX + safeScreenFrame.origin.x -= cgbounds.minX - visibleFrame.minX return safeScreenFrame } - var stageStripFreeFrame: NSRect { + var safeScreenFrame: NSRect { var frame = visibleFrame if Defaults[.respectStageManager], @@ -76,9 +65,7 @@ extension NSScreen { } var displayBounds: CGRect { - guard - let displayID - else { + guard let displayID else { NSLog("Error: Failed to get NSScreen.displayID in NSScreen.displayBounds") return frame.flipY(screen: self) } @@ -93,12 +80,10 @@ extension NSScreen { func isSameScreen(_ other: NSScreen) -> Bool { displayID == other.displayID } -} -// MARK: - Calculate physical screen size + // MARK: - Calculate physical screen size -extension NSScreen { - // Returns diagonal size in inches + /// Returns diagonal size in inches var diagonalSize: CGFloat { let unitsPerInch = unitsPerInch let screenSizeInInches = CGSize( @@ -106,36 +91,36 @@ extension NSScreen { height: frame.height / unitsPerInch.height ) - // Just the pythagorean theorem - let diagonalSize = sqrt(pow(screenSizeInInches.width, 2) + pow(screenSizeInInches.height, 2)) + let diagonalSize = sqrt( + pow(screenSizeInInches.width, 2) + pow(screenSizeInInches.height, 2) + ) return diagonalSize } private var unitsPerInch: CGSize { - // We need to convert from mm to inch because CGDisplayScreenSize returns units in mm. - let millimetersPerInch: CGFloat = 25.4 - let screenDescription = deviceDescription - if let displayUnitSize = (screenDescription[NSDeviceDescriptionKey.size] as? NSValue)?.sizeValue, - let screenNumber = (screenDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value { - let displayPhysicalSize = CGDisplayScreenSize(screenNumber) - - return CGSize( - width: millimetersPerInch * displayUnitSize.width / displayPhysicalSize.width, - height: millimetersPerInch * displayUnitSize.height / displayPhysicalSize.height - ) - } else { - // this is the same as what CoreGraphics assumes if no EDID data is available from the display device - // https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize?language=objc + + guard let displayID, + let displayUnitSize = (screenDescription[NSDeviceDescriptionKey.size] as? NSValue)?.sizeValue + else { + // This is the same as what CoreGraphics assumes if no EDID data is available from the display device + // https://developer.apple.com/documentation/coregraphics/cgdisplayscreensize(_:) return CGSize(width: 72.0, height: 72.0) } + + // We need to convert from mm to inch because CGDisplayScreenSize returns units in mm. + let millimetersPerInch: CGFloat = 25.4 + let displayPhysicalSize = CGDisplayScreenSize(displayID) + + return CGSize( + width: millimetersPerInch * displayUnitSize.width / displayPhysicalSize.width, + height: millimetersPerInch * displayUnitSize.height / displayPhysicalSize.height + ) } -} -// MARK: - Screen overlap + // MARK: - Screen overlap -extension NSScreen { private func verticalOverlap(with other: NSScreen) -> CGFloat { let a = frame let b = other.frame diff --git a/Loop/Extensions/UNNotification+Extensions.swift b/Loop/Extensions/UNNotification+Extensions.swift index 94d82010..2efa80f5 100644 --- a/Loop/Extensions/UNNotification+Extensions.swift +++ b/Loop/Extensions/UNNotification+Extensions.swift @@ -8,7 +8,7 @@ import SwiftUI import UserNotifications -// Thanks https://stackoverflow.com/questions/45226847/unnotificationattachment-failing-to-attach-image +/// Thanks https://stackoverflow.com/questions/45226847/unnotificationattachment-failing-to-attach-image extension UNNotificationAttachment { static func create(_ imgData: NSData) -> UNNotificationAttachment? { let imageFileIdentifier = UUID().uuidString + ".jpeg" diff --git a/Loop/Icon/Icon.swift b/Loop/Icon/Icon.swift index 7634853d..981c4505 100644 --- a/Loop/Icon/Icon.swift +++ b/Loop/Icon/Icon.swift @@ -23,7 +23,6 @@ import SwiftUI /// - Black Hole: 2500 Loops /// - Summer: 3000 Loops /// - Master: 5000 Loops - struct Icon: Hashable, LuminareSelectionData { var name: String var assetName: String @@ -35,7 +34,7 @@ struct Icon: Hashable, LuminareSelectionData { } #if RELEASE - // Remove developer icon in release builds + /// Remove developer icon in release builds static let all: [Icon] = [ .classic, .holo, diff --git a/Loop/Icon/IconManager.swift b/Loop/Icon/IconManager.swift index ed3e89df..460064ed 100644 --- a/Loop/Icon/IconManager.swift +++ b/Loop/Icon/IconManager.swift @@ -11,6 +11,7 @@ import Scribe import SwiftUI import UserNotifications +@Loggable(style: .static) enum IconManager { static func returnUnlockedIcons() -> [Icon] { var returnValue: [Icon] = [] @@ -32,12 +33,12 @@ enum IconManager { } } - // This function is run at startup to set the current icon to the user's set icon. + /// This function is run at startup to set the current icon to the user's set icon. static func refreshCurrentAppIcon() { let iconName = Defaults[.currentIcon] guard let image = NSImage(named: iconName) else { - Log.error("Failed to load icon: \(iconName)", category: .iconManager) + log.error("Failed to load icon: \(iconName)") return } @@ -53,7 +54,7 @@ enum IconManager { NSApp.applicationIconImage = image } - Log.info("Set app icon to: \(iconName)", category: .iconManager) + log.info("Set app icon to: \(iconName)") } static func checkIfUnlockedNewIcon() { diff --git a/Loop/InternetAccessPolicy.plist b/Loop/InternetAccessPolicy.plist index 67ec63e9..cafb0397 100644 --- a/Loop/InternetAccessPolicy.plist +++ b/Loop/InternetAccessPolicy.plist @@ -26,6 +26,22 @@ DenyConsequences If you deny these connections, you will not be notified about new versions of Loop. + + IsIncoming + + Host + *.githubusercontent.com + NetworkProtocol + TCP + Port + 443 + Relevance + Default + Purpose + Loop downloads software updates from GitHub if a newer version is available. + DenyConsequences + If you deny these connections, Loop will not be able to install software updates by iteslf. + diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index e01fc912..4a66f9e5 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -8430,6 +8430,7 @@ } }, "Hide until direction is chosen" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -8511,6 +8512,10 @@ } } }, + "Hide when no action is selected" : { + "comment" : "Toggle label for hiding the radial menu when no action is selected.", + "isCommentAutoGenerated" : true + }, "Horizontal Center Half" : { "comment" : "Window action", "localizations" : { @@ -32744,5 +32749,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Loop/Migration/Migrator.swift b/Loop/Migration/Migrator.swift index be6179f4..26db9717 100644 --- a/Loop/Migration/Migrator.swift +++ b/Loop/Migration/Migrator.swift @@ -107,7 +107,8 @@ enum MigratorError: LocalizedError { } } -// Adds functionality for saving, loading, and managing window actions. +/// Adds functionality for saving, loading, and managing window actions. +@Loggable(style: .static) enum Migrator { private static var documentsDirectory: URL? { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first @@ -190,9 +191,7 @@ private extension Migrator { } /// Saves the keybinds in the specified directory URL. - static func saveKeybinds(_: SavedKeybindsFormat, in directoryURL: URL) async throws { - let keybinds = SavedKeybindsFormat.generateFromDefaults() - + private static func saveKeybinds(_ keybinds: SavedKeybindsFormat, in directoryURL: URL) async throws { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] @@ -316,36 +315,36 @@ private extension Migrator { } /// Imports keybinds from a JSON string. - static func importKeybinds(from jsonString: String, onSuccess: () -> ()) async throws { + private static func importKeybinds(from jsonString: String, onSuccess: () -> ()) async throws { guard let data = jsonString.data(using: .utf8) else { throw MigratorError.failedToReadFile } - /// First, try to import the general Loop keybinds format. + // First, try to import the general Loop keybinds format. do { - let savedData = try await importLoopKeybinds(from: data) + let savedData = try importLoopKeybinds(from: data) await updateDefaults(with: savedData, onSuccess: onSuccess) return } catch { - Log.error("Error importing Loop keybinds: \(error)", category: .migrator) + log.error("Error importing Loop keybinds: \(error)") } - /// If that fails, try to import the old Loop (pre 1.2.0) keybinds format. + // If that fails, try to import the old Loop (pre 1.2.0) keybinds format. do { - let savedData = try await importLoopLegacyKeybinds(from: data) + let savedData = try importLoopLegacyKeybinds(from: data) await updateDefaults(with: savedData, onSuccess: onSuccess) return } catch { - Log.error("Error importing Loop (pre 1.2.0) keybinds: \(error)", category: .migrator) + log.error("Error importing Loop (pre 1.2.0) keybinds: \(error)") } - /// If that fails, try to import the Rectangle keybinds format. + // If that fails, try to import the Rectangle keybinds format. do { - let savedData = try await importRectangleKeybinds(from: data) + let savedData = try importRectangleKeybinds(from: data) await updateDefaults(with: savedData, onSuccess: onSuccess) return } catch { - Log.error("Error importing Rectangle keybinds: \(error)", category: .migrator) + log.error("Error importing Rectangle keybinds: \(error)") } // If all attempts fail, show an error alert. @@ -353,21 +352,21 @@ private extension Migrator { } /// Tries to import Loop's keybinds format. - static func importLoopKeybinds(from data: Data) async throws -> SavedKeybindsFormat { + static func importLoopKeybinds(from data: Data) throws -> SavedKeybindsFormat { let decoder = JSONDecoder() let keybinds = try decoder.decode(SavedKeybindsFormat.self, from: data) return keybinds } /// Tries to import Loop's old (pre 1.2.0) keybinds format. - static func importLoopLegacyKeybinds(from data: Data) async throws -> SavedKeybindsFormat { + static func importLoopLegacyKeybinds(from data: Data) throws -> SavedKeybindsFormat { let decoder = JSONDecoder() let keybinds = try decoder.decode([SavedWindowActionFormat].self, from: data) return SavedKeybindsFormat(version: nil, triggerKey: nil, actions: keybinds) } /// Tries to import Rectangle's keybinds format. - static func importRectangleKeybinds(from data: Data) async throws -> SavedKeybindsFormat { + static func importRectangleKeybinds(from data: Data) throws -> SavedKeybindsFormat { let keybinds = try RectangleTranslationLayer.importKeybinds(from: data) return SavedKeybindsFormat(version: nil, triggerKey: nil, actions: keybinds) } diff --git a/Loop/Migration/RectangleTranslationLayer.swift b/Loop/Migration/RectangleTranslationLayer.swift index ed27ac6c..056ea532 100644 --- a/Loop/Migration/RectangleTranslationLayer.swift +++ b/Loop/Migration/RectangleTranslationLayer.swift @@ -20,7 +20,7 @@ struct RectangleConfig: Codable { let shortcuts: [String: RectangleShortcut] } -// Encapsulate the functions within an enum to provide a namespace +/// Encapsulate the functions within an enum to provide a namespace enum RectangleTranslationLayer { /// Maps Rectangle direction keys to Loop's WindowDirection enum. private static let directionMapping: [String: WindowDirection] = [ diff --git a/Loop/Private APIs/SkyLightSymbolLoader.swift b/Loop/Private APIs/SkyLightSymbolLoader.swift index c15b505d..40f3d99a 100644 --- a/Loop/Private APIs/SkyLightSymbolLoader.swift +++ b/Loop/Private APIs/SkyLightSymbolLoader.swift @@ -9,12 +9,13 @@ import CoreGraphics import Darwin import Scribe +@Loggable(style: .static) enum SkyLightSymbolLoader { private static let frameworkPath = "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight" private static let handle: UnsafeMutableRawPointer? = { guard let handle = dlopen(frameworkPath, RTLD_LAZY) else { - Log.error("failed to open \(frameworkPath)", category: .skyLightSymbolLoader) + log.error("failed to open \(frameworkPath)") return nil } return handle @@ -22,7 +23,7 @@ enum SkyLightSymbolLoader { private static func loadSymbol(_ name: StaticString) -> T? { guard let handle else { - Log.error("no handle; cannot load symbol \(name)", category: .skyLightSymbolLoader) + log.error("no handle; cannot load symbol \(name)") return nil } @@ -31,9 +32,9 @@ enum SkyLightSymbolLoader { guard let sym = dlsym(handle, name.description) else { if let err = dlerror() { - Log.error("failed to load symbol \(name): \(String(cString: err))", category: .skyLightSymbolLoader) + log.error("failed to load symbol \(name): \(String(cString: err))") } else { - Log.error("failed to load symbol \(name)", category: .skyLightSymbolLoader) + log.error("failed to load symbol \(name)") } return nil } @@ -113,13 +114,13 @@ struct SLSWindowCaptureOptions: OptionSet { static let ignoreGlobalClipShape = Self(rawValue: 1 << 11) - // On a retina display, this captures at 1 pt : 4 px + /// On a retina display, this captures at 1 pt : 4 px static let nominalResolution = Self(rawValue: 1 << 9) - // Captures at 1 pt : 1px + /// Captures at 1 pt : 1px static let bestResolution = Self(rawValue: 1 << 8) - // When Stage Manager is enabled, screenshots can become skewed. This param gets us full-size screenshots regardless + /// When Stage Manager is enabled, screenshots can become skewed. This param gets us full-size screenshots regardless static let fullSize = Self(rawValue: 1 << 19) } diff --git a/Loop/Private APIs/SkyLightToolBelt.swift b/Loop/Private APIs/SkyLightToolBelt.swift index 139bc6b5..7a8a93a6 100644 --- a/Loop/Private APIs/SkyLightToolBelt.swift +++ b/Loop/Private APIs/SkyLightToolBelt.swift @@ -9,6 +9,7 @@ import Scribe import SwiftUI /// A wrapper for functions defined in `SkyLightSymbolLoader` +@Loggable(style: .static) enum SkyLightToolBelt { /// Brings the window’s owning process to the front using SkyLight APIs. /// - Parameters: @@ -17,7 +18,7 @@ enum SkyLightToolBelt { /// - Returns: Whether this operation was successful. static func makeFrontProcess(windowID: CGWindowID, pid: pid_t) -> Bool { guard let SLPSSetFrontProcessWithOptions = SkyLightSymbolLoader.SLPSSetFrontProcessWithOptions else { - Log.error("Failed to load SkyLight symbols in \(#function)", category: .skyLightToolBelt) + log.error("Failed to load SkyLight symbols in \(#function)") return false } @@ -25,7 +26,7 @@ enum SkyLightToolBelt { let status = GetProcessForPID(pid, &psn) guard status == noErr else { - Log.error("Failed to get PSN: \(status)", category: .skyLightToolBelt) + log.error("Failed to get PSN: \(status)") return false } @@ -36,7 +37,7 @@ enum SkyLightToolBelt { ) guard cgStatus == .success else { - Log.error("Failed to set frontmost process with status: \(cgStatus.rawValue)", category: .skyLightToolBelt) + log.error("Failed to set frontmost process with status: \(cgStatus.rawValue)") return false } @@ -56,7 +57,7 @@ enum SkyLightToolBelt { /// - Returns: Whether this operation was successful. static func makeKeyWindow(windowID: CGWindowID, pid: pid_t) -> Bool { guard let SLPSPostEventRecordTo = SkyLightSymbolLoader.SLPSPostEventRecordTo else { - Log.error("Failed to load SkyLight symbols in \(#function)", category: .skyLightToolBelt) + log.error("Failed to load SkyLight symbols in \(#function)") return false } @@ -65,17 +66,17 @@ enum SkyLightToolBelt { let status = GetProcessForPID(pid, &psn) guard status == noErr else { - Log.error("Failed to get PSN: \(status)", category: .skyLightToolBelt) + log.error("Failed to get PSN: \(status)") return false } - /// `0x01` is left click down, `0x02` is left click up (see `CGEventType`) + // `0x01` is left click down, `0x02` is left click up (see `CGEventType`) for byte in [0x01, 0x02] { - /// Create raw `SLSEvent` data. - /// Future consideration: instead of manually creating the bytes here, investigate: - /// - Creating a `SLSEvent` (likely analogous to `CGEvent`) - /// - Apply an identifier to the event to help Loop differentiate events that originate from itself - /// - Converting the `SLSEvent` to data using `SLEventCreateData` in SkyLight + // Create raw `SLSEvent` data. + // Future consideration: instead of manually creating the bytes here, investigate: + // - Creating a `SLSEvent` (likely analogous to `CGEvent`) + // - Apply an identifier to the event to help Loop differentiate events that originate from itself + // - Converting the `SLSEvent` to data using `SLEventCreateData` in SkyLight var bytes = [UInt8](repeating: 0, count: 0xF8) bytes[0x04] = 0xF8 bytes[0x08] = UInt8(byte) @@ -87,7 +88,7 @@ enum SkyLightToolBelt { } guard cgStatus == .success else { - Log.error("Failed to click frontmost process with status: \(cgStatus.rawValue)", category: .skyLightToolBelt) + log.error("Failed to click frontmost process with status: \(cgStatus.rawValue)") return false } } @@ -104,7 +105,7 @@ enum SkyLightToolBelt { guard let SLSDefaultConnectionForThread = SkyLightSymbolLoader.SLSDefaultConnectionForThread, let SLSSetWindowBackgroundBlurRadius = SkyLightSymbolLoader.SLSSetWindowBackgroundBlurRadius else { - Log.error("Failed to load SkyLight symbols in \(#function)", category: .skyLightToolBelt) + log.error("Failed to load SkyLight symbols in \(#function)") return } @@ -116,7 +117,7 @@ enum SkyLightToolBelt { ) if status != noErr { - Log.error("Failed to set window background blur radius", category: .skyLightToolBelt) + log.error("Failed to set window background blur radius") } } @@ -127,7 +128,7 @@ enum SkyLightToolBelt { guard let SLSMainConnectionID = SkyLightSymbolLoader.SLSMainConnectionID, let SLSHWCaptureWindowList = SkyLightSymbolLoader.SLSHWCaptureWindowList else { - Log.error("Failed to load SkyLight symbols in \(#function)", category: .skyLightToolBelt) + log.error("Failed to load SkyLight symbols in \(#function)") return [] } @@ -157,7 +158,7 @@ enum SkyLightToolBelt { let SLSWindowIteratorGetWindowID = SkyLightSymbolLoader.SLSWindowIteratorGetWindowID, let SLSWindowIteratorGetResolvedCornerRadii = SkyLightSymbolLoader.SLSWindowIteratorGetResolvedCornerRadii else { - Log.error("Failed to load SkyLight symbols in \(#function)", category: .skyLightToolBelt) + log.error("Failed to load SkyLight symbols in \(#function)") return nil } @@ -197,7 +198,7 @@ enum SkyLightToolBelt { let SLSWindowIteratorGetTags = SkyLightSymbolLoader.SLSWindowIteratorGetTags, let SLSWindowIteratorGetAttributes = SkyLightSymbolLoader.SLSWindowIteratorGetAttributes else { - Log.error("Failed to load SkyLight symbols in \(#function)", category: .skyLightToolBelt) + log.error("Failed to load SkyLight symbols in \(#function)") return false } diff --git a/Loop/Settings Window/Loop/AboutConfiguration.swift b/Loop/Settings Window/Loop/AboutConfiguration.swift index 1556a7ec..2ba273ee 100644 --- a/Loop/Settings Window/Loop/AboutConfiguration.swift +++ b/Loop/Settings Window/Loop/AboutConfiguration.swift @@ -44,7 +44,7 @@ final class AboutConfigurationModel: ObservableObject { ) ] - // A max of 28 W's can fit in here :) + /// A max of 28 W's can fit in here :) private let upToDateText: [String] = [ .init(localized: "No updates available message 01", defaultValue: "Engage! …in the current version, it's the latest."), .init(localized: "No updates available message 02", defaultValue: "This app is more up to date than my diary entries!"), diff --git a/Loop/Settings Window/Loop/AdvancedConfiguration.swift b/Loop/Settings Window/Loop/AdvancedConfiguration.swift index c4951953..fb9e03f9 100644 --- a/Loop/Settings Window/Loop/AdvancedConfiguration.swift +++ b/Loop/Settings Window/Loop/AdvancedConfiguration.swift @@ -11,6 +11,7 @@ import Luminare import Scribe import SwiftUI +@Loggable @MainActor final class AdvancedConfigurationModel: ObservableObject { @Published private(set) var showResetRadialMenuActionsSuccessIndicator = false @@ -72,7 +73,7 @@ final class AdvancedConfigurationModel: ObservableObject { showSuccessIndicator(\.showImportKeybindsSuccessIndicator) } } catch { - Log.error("Error importing keybinds: \(error)", category: .advancedConfigurationModel) + log.error("Error importing keybinds: \(error)") } } } @@ -85,7 +86,7 @@ final class AdvancedConfigurationModel: ObservableObject { showSuccessIndicator(\.showExportKeybindsSuccessIndicator) } } catch { - Log.error("Error exporting keybinds: \(error)", category: .advancedConfigurationModel) + log.error("Error exporting keybinds: \(error)") } } } @@ -126,7 +127,7 @@ struct AdvancedConfigurationView: View { @Default(.useSystemWindowManagerWhenAvailable) var useSystemWindowManagerWhenAvailable @Default(.ignoreLowPowerMode) var ignoreLowPowerMode @Default(.animateWindowResizes) var animateWindowResizes - @Default(.hideUntilDirectionIsChosen) var hideUntilDirectionIsChosen + @Default(.hideOnNoSelection) var hideOnNoSelection @Default(.disableCursorInteraction) var disableCursorInteraction @Default(.ignoreFullscreen) var ignoreFullscreen @Default(.hapticFeedback) var hapticFeedback @@ -200,7 +201,7 @@ struct AdvancedConfigurationView: View { private var radialMenuSection: some View { LuminareSection(String(localized: "Radial Menu", comment: "Section header shown in settings")) { - LuminareToggle("Hide until direction is chosen", isOn: $hideUntilDirectionIsChosen) + LuminareToggle("Hide when no action is selected", isOn: $hideOnNoSelection) LuminareToggle(isOn: $enableRadialMenuCustomization) { HStack { diff --git a/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift b/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift index 98f39c1e..9fbcf858 100644 --- a/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift +++ b/Loop/Settings Window/Settings/Behavior/BehaviorConfiguration.swift @@ -7,8 +7,6 @@ import Defaults import Luminare -import Scribe -import ServiceManagement import SwiftUI struct BehaviorConfigurationView: View { @@ -56,17 +54,6 @@ struct BehaviorConfigurationView: View { private var generalSection: some View { LuminareSection(String(localized: "General", comment: "Section header shown in settings")) { LuminareToggle("Launch at login", isOn: $launchAtLogin) - .onChange(of: launchAtLogin) { _ in - do { - if launchAtLogin { - try SMAppService().register() - } else { - try SMAppService().unregister() - } - } catch { - Log.error("Failed to \(launchAtLogin ? "register" : "unregister") login item: \(error.localizedDescription)", category: .behaviorConfigurationView) - } - } LuminareToggle("Hide menu bar icon", isOn: $hideMenuBarIcon) diff --git a/Loop/Settings Window/Settings/Behavior/Padding Configuration/PaddingConfigurationView.swift b/Loop/Settings Window/Settings/Behavior/Padding Configuration/PaddingConfigurationView.swift index 6059a1c7..d8b2770c 100644 --- a/Loop/Settings Window/Settings/Behavior/Padding Configuration/PaddingConfigurationView.swift +++ b/Loop/Settings Window/Settings/Behavior/Padding Configuration/PaddingConfigurationView.swift @@ -122,7 +122,7 @@ struct PaddingConfigurationView: View { } ), in: range, - format: .number.precision(.fractionLength(0...0)), + format: .number.precision(.fractionLength(0...1)), clampsUpper: false, suffix: Text("px", comment: "Unit symbol: pixels") ) @@ -134,41 +134,41 @@ struct PaddingConfigurationView: View { String(localized: "Top", comment: "Label for a slider in Loop’s padding settings"), value: $paddingModel.top.doubleBinding, in: range, - format: .number.precision(.fractionLength(0...0)), + format: .number.precision(.fractionLength(0...1)), clampsUpper: false, suffix: Text("px", comment: "Unit symbol: pixels") ) - .luminareSliderLayout(.compact(textBoxWidth: 64)) + .luminareSliderLayout(.compact(textBoxWidth: 76)) LuminareSlider( String(localized: "Bottom", comment: "Label for a slider in Loop’s padding settings"), value: $paddingModel.bottom.doubleBinding, in: range, - format: .number.precision(.fractionLength(0...0)), + format: .number.precision(.fractionLength(0...1)), clampsUpper: false, suffix: Text("px", comment: "Unit symbol: pixels") ) - .luminareSliderLayout(.compact(textBoxWidth: 64)) + .luminareSliderLayout(.compact(textBoxWidth: 76)) LuminareSlider( String(localized: "Right", comment: "Label for a slider in Loop’s padding settings"), value: $paddingModel.right.doubleBinding, in: range, - format: .number.precision(.fractionLength(0...0)), + format: .number.precision(.fractionLength(0...1)), clampsUpper: false, suffix: Text("px", comment: "Unit symbol: pixels") ) - .luminareSliderLayout(.compact(textBoxWidth: 64)) + .luminareSliderLayout(.compact(textBoxWidth: 76)) LuminareSlider( String(localized: "Left", comment: "Label for a slider in Loop’s padding settings"), value: $paddingModel.left.doubleBinding, in: range, - format: .number.precision(.fractionLength(0...0)), + format: .number.precision(.fractionLength(0...1)), clampsUpper: false, suffix: Text("px", comment: "Unit symbol: pixels") ) - .luminareSliderLayout(.compact(textBoxWidth: 64)) + .luminareSliderLayout(.compact(textBoxWidth: 76)) } } @@ -178,16 +178,16 @@ struct PaddingConfigurationView: View { String(localized: "Window gaps", comment: "Label for a slider in Loop’s padding settings"), value: $paddingModel.window.doubleBinding, in: range, - format: .number.precision(.fractionLength(0...0)), + format: .number.precision(.fractionLength(0...1)), clampsUpper: false, suffix: Text("px", comment: "Unit symbol: pixels") ) - .luminareSliderLayout(.compact(textBoxWidth: 64)) + .luminareSliderLayout(.compact(textBoxWidth: 76)) LuminareSlider( value: $paddingModel.externalBar.doubleBinding, in: range, - format: .number.precision(.fractionLength(0...0)), + format: .number.precision(.fractionLength(0...1)), suffix: Text("px", comment: "Unit symbol: pixels") ) { Text("External bar", comment: "Label for a slider in Loop’s padding settings") @@ -197,7 +197,7 @@ struct PaddingConfigurationView: View { .padding(6) } } - .luminareSliderLayout(.compact(textBoxWidth: 64)) + .luminareSliderLayout(.compact(textBoxWidth: 76)) } } } diff --git a/Loop/Settings Window/Settings/Behavior/Padding Configuration/PaddingPreviewView.swift b/Loop/Settings Window/Settings/Behavior/Padding Configuration/PaddingPreviewView.swift index dec8c288..2b823b6a 100644 --- a/Loop/Settings Window/Settings/Behavior/Padding Configuration/PaddingPreviewView.swift +++ b/Loop/Settings Window/Settings/Behavior/Padding Configuration/PaddingPreviewView.swift @@ -11,9 +11,9 @@ import SwiftUI struct PaddingPreviewView: View { @Environment(\.luminareAnimation) private var luminareAnimation - @Binding var model: PaddingModel + @Binding var model: PaddingConfiguration - init(_ paddingModel: Binding) { + init(_ paddingModel: Binding) { self._model = paddingModel } @@ -38,7 +38,6 @@ struct PaddingPreviewView: View { .animation(luminareAnimation, value: model) } - @ViewBuilder func blurredWindow() -> some View { VisualEffectView(material: .hudWindow, blendingMode: .withinWindow) .overlay { diff --git a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift index ebf99438..7d35b280 100644 --- a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift +++ b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift @@ -134,7 +134,7 @@ struct Keycorder: View { /// Handles key presses and updates the current keybind func handleKeyDown(with event: NSEvent) { - /// Get current selected keys that aren't modifiers + // Get current selected keys that aren't modifiers let currentKeys = selectionKeybind + [event.keyCode] .map { $0.baseKey(flags: event.modifierFlags) } @@ -161,7 +161,7 @@ struct Keycorder: View { shouldError = false - /// Make sure we don't go over the key limit + // Make sure we don't go over the key limit guard finalKeys.count <= keyLimit else { errorMessage = "You can only use up to \(keyLimit) keys in a keybind." shake() @@ -187,7 +187,9 @@ struct Keycorder: View { eventMonitor?.stop() eventMonitor = nil - LoopManager.shared.keybindTrigger.start() + Task { + await LoopManager.shared.keybindTrigger.start() + } } private func checkValidKeybindConditions() -> Bool { diff --git a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift index ea7eae33..41c8edc9 100644 --- a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift +++ b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift @@ -179,7 +179,9 @@ struct TriggerKeycorder: View { eventMonitor?.stop() eventMonitor = nil - LoopManager.shared.keybindTrigger.start() + Task { + await LoopManager.shared.keybindTrigger.start() + } } private func shake() { diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift index 78afda9d..06a77328 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift @@ -59,10 +59,10 @@ struct CustomActionConfigurationView: View { GeometryReader { geo in ZStack { if action.sizeMode == .custom { - let frame = action.getFrame( + let frame = WindowFrameResolver.getFrame( + for: action, window: nil, - bounds: CGRect(origin: .zero, size: geo.size), - disablePadding: true + bounds: CGRect(origin: .zero, size: geo.size) ) blurredWindow() @@ -135,7 +135,6 @@ struct CustomActionConfigurationView: View { } } - @ViewBuilder private func tabPicker() -> some View { LuminarePicker( elements: Tab.allCases, @@ -152,7 +151,6 @@ struct CustomActionConfigurationView: View { .luminareRoundingBehavior(top: true) } - @ViewBuilder private func unitToggle() -> some View { LuminareToggle("Use pixels", isOn: Binding(get: { action.unit == .pixels }, set: { action.unit = $0 ? .pixels : .percentage })) .onChange(of: actionUnit) { unit in @@ -165,7 +163,6 @@ struct CustomActionConfigurationView: View { } } - @ViewBuilder private func actionButtons() -> some View { HStack(spacing: 8) { Button("Preview") {} @@ -176,11 +173,9 @@ struct CustomActionConfigurationView: View { pressing: { pressing in if pressing { guard let screen = NSScreen.main else { return } - previewController.open( - screen: screen, - window: nil, - startingAction: action - ) + let context = ResizeContext(screen: screen) + context.setAction(to: action, parent: nil) + previewController.open(context: context) } else { previewController.close() } @@ -198,7 +193,6 @@ struct CustomActionConfigurationView: View { .luminareCornerRadius(8) } - @ViewBuilder private func positionConfiguration() -> some View { LuminareSection(outerPadding: 0) { LuminareToggle( @@ -298,7 +292,6 @@ struct CustomActionConfigurationView: View { } } - @ViewBuilder private func sizeConfiguration() -> some View { LuminareSection(outerPadding: 0) { LuminarePicker( @@ -364,7 +357,6 @@ struct CustomActionConfigurationView: View { } } - @ViewBuilder private func blurredWindow() -> some View { VisualEffectView(material: .hudWindow, blendingMode: .withinWindow) .overlay { diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/CycleActionConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/CycleActionConfigurationView.swift index 1077698c..34834475 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/CycleActionConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/CycleActionConfigurationView.swift @@ -53,7 +53,8 @@ struct CycleActionConfigurationView: View { items: Binding( get: { action.cycle ?? [] - }, set: { newValue in + }, + set: { newValue in action.cycle = newValue } ), diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift index 41f41442..4ca5a456 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/StashActionConfigurationView.swift @@ -62,10 +62,10 @@ struct StashActionConfigurationView: View { GeometryReader { geo in ZStack { if action.sizeMode == .custom { - let frame = action.getFrame( + let frame = WindowFrameResolver.getFrame( + for: action, window: nil, - bounds: CGRect(origin: .zero, size: geo.size), - disablePadding: true + bounds: CGRect(origin: .zero, size: geo.size) ) blurredWindow() @@ -131,7 +131,6 @@ struct StashActionConfigurationView: View { } } - @ViewBuilder private func tabPicker() -> some View { LuminarePicker( elements: Tab.allCases, @@ -148,7 +147,6 @@ struct StashActionConfigurationView: View { .luminareRoundingBehavior(top: true, bottom: true) } - @ViewBuilder private func actionButtons() -> some View { HStack(spacing: 8) { Button("Preview") {} @@ -159,11 +157,9 @@ struct StashActionConfigurationView: View { pressing: { pressing in if pressing { guard let screen = NSScreen.main else { return } - previewController.open( - screen: screen, - window: nil, - startingAction: action - ) + let context = ResizeContext(screen: screen) + context.setAction(to: action, parent: nil) + previewController.open(context: context) } else { previewController.close() } @@ -181,7 +177,6 @@ struct StashActionConfigurationView: View { .luminareCornerRadius(8) } - @ViewBuilder private func positionConfiguration() -> some View { LuminareSection(outerPadding: 0) { if action.positionMode ?? .generic == .generic { @@ -238,7 +233,6 @@ struct StashActionConfigurationView: View { } } - @ViewBuilder private func sizeConfiguration() -> some View { LuminareSection(outerPadding: 0) { LuminarePicker( @@ -304,7 +298,6 @@ struct StashActionConfigurationView: View { } } - @ViewBuilder private func blurredWindow() -> some View { VisualEffectView(material: .hudWindow, blendingMode: .withinWindow) .overlay { diff --git a/Loop/Settings Window/SettingsContentView.swift b/Loop/Settings Window/SettingsContentView.swift index b0ada446..6836b0c5 100644 --- a/Loop/Settings Window/SettingsContentView.swift +++ b/Loop/Settings Window/SettingsContentView.swift @@ -56,10 +56,14 @@ struct SettingsContentView: View { .frame(width: 390) if model.showInspector { - ZStack { + // We use an overlay instead of a ZStack so the inspector’s contents + // don’t influence the layout of the surrounding views (mainly as a precaution) + Color.clear.overlay { if model.showPreview || showRadialMenuGuide { - LuminarePreviewView() - .allowsHitTesting(false) + PreviewView(viewModel: model.previewViewModel) + .onGeometryChange(for: CGSize.self, of: \.size) { + model.setPreviewBounds(CGRect(origin: .zero, size: $0)) + } } if model.showRadialMenu { @@ -71,7 +75,6 @@ struct SettingsContentView: View { RadialMenuActionsGuide() } } - .compositingGroup() .animation(animation, value: [model.showRadialMenu, model.showPreview]) .padding(12) .frame(width: 520) diff --git a/Loop/Settings Window/SettingsTab.swift b/Loop/Settings Window/SettingsTab.swift index 805ba745..add77b9c 100644 --- a/Loop/Settings Window/SettingsTab.swift +++ b/Loop/Settings Window/SettingsTab.swift @@ -126,7 +126,7 @@ struct SettingsTabIconView: View { .frame(width: 22, height: 22) } - // Mimics macOS Tahoe's icon shine + /// Mimics macOS Tahoe's icon shine private func borderShine(in shape: some InsettableShape) -> some View { shape .strokeBorder(.white, lineWidth: 1) diff --git a/Loop/Settings Window/SettingsWindowManager.swift b/Loop/Settings Window/SettingsWindowManager.swift index 9a913634..bf829033 100644 --- a/Loop/Settings Window/SettingsWindowManager.swift +++ b/Loop/Settings Window/SettingsWindowManager.swift @@ -11,6 +11,7 @@ import Luminare import Scribe import SwiftUI +@Loggable @MainActor final class SettingsWindowManager: ObservableObject { static let shared = SettingsWindowManager() @@ -23,9 +24,12 @@ final class SettingsWindowManager: ObservableObject { @Published private(set) var previewedParentAction: WindowAction? = nil @Published private(set) var previewedAction: WindowAction = .init(.noSelection) { - didSet { radialMenuViewModel.setAction(to: previewedAction, parent: previewedParentAction) } + didSet { updatePreviewContexts() } } + private(set) var previewBounds: CGRect = .zero + private(set) var didSetBounds: Bool = false + @Published var showRadialMenu: Bool = true @Published var showPreview: Bool = true @@ -55,15 +59,15 @@ final class SettingsWindowManager: ObservableObject { } let radialMenuViewModel: RadialMenuViewModel + let previewViewModel: PreviewViewModel var window: NSWindow? { controller?.window } private init() { - let startingAction: WindowAction = .init(.noAction) - - self.radialMenuViewModel = .init(startingAction: startingAction, window: nil, previewMode: true) + self.radialMenuViewModel = .init(isSettingsPreview: true) + self.previewViewModel = .init(isSettingsPreview: true) if let firstAction = RadialMenuAction.userConfiguredActions.first?.resolved { setPreviewedAction(to: firstAction) @@ -100,7 +104,7 @@ final class SettingsWindowManager: ObservableObject { NSApp.activate(ignoringOtherApps: true) } - Log.success("Settings window opened", category: .settingsWindowManager) + log.success("Settings window opened") } func close() { @@ -108,7 +112,7 @@ final class SettingsWindowManager: ObservableObject { controller.close() self.controller = nil - Log.success("Settings window closed", category: .settingsWindowManager) + log.success("Settings window closed") } stopTimer() @@ -129,7 +133,7 @@ final class SettingsWindowManager: ObservableObject { try await Task.sleep(for: .seconds(1)) while !Task.isCancelled { - if controller?.window?.isKeyWindow == true { + if NSApp.isActive { setNextPreviewedAction() } @@ -178,4 +182,22 @@ final class SettingsWindowManager: ObservableObject { previewedAction = newAction } } + + func setPreviewBounds(_ bounds: CGRect) { + previewBounds = bounds + didSetBounds = true + + updatePreviewContexts() + } + + private func updatePreviewContexts() { + guard didSetBounds else { + return + } + + let context = ResizeContext(bounds: previewBounds) + context.setAction(to: previewedAction, parent: previewedParentAction) + radialMenuViewModel.updateContext(with: context) + previewViewModel.updateContext(with: context, isScreenSwitch: false) + } } diff --git a/Loop/Settings Window/Theming/IconConfiguration.swift b/Loop/Settings Window/Theming/IconConfiguration.swift index ce858a6a..59ec3af6 100644 --- a/Loop/Settings Window/Theming/IconConfiguration.swift +++ b/Loop/Settings Window/Theming/IconConfiguration.swift @@ -220,9 +220,10 @@ struct IconVew: View { Image(systemName: "lock") .foregroundStyle(.secondary) - Text(nextUnlockCount == icon.unlockTime ? - .init(localized: "Loops left to unlock new icon", defaultValue: "\(loopsLeft) Loops left") : - .init(localized: "App icon is locked", defaultValue: "Locked") + Text( + nextUnlockCount == icon.unlockTime ? + .init(localized: "Loops left to unlock new icon", defaultValue: "\(loopsLeft) Loops left") : + .init(localized: "App icon is locked", defaultValue: "Locked") ) .font(.caption) .foregroundColor(.secondary) diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift index 59ac2705..953f71d9 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -91,7 +91,6 @@ struct RadialMenuActionItemView: View { } } - @ViewBuilder private var label: some View { actionIndicator .background { @@ -113,7 +112,6 @@ struct RadialMenuActionItemView: View { } } - @ViewBuilder var actionIndicator: some View { HStack(spacing: 2) { Button { diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift index 835a9f19..b77d45cc 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift @@ -76,7 +76,6 @@ struct RadialMenuActionsGuide: View { .animation(luminareAnimation, value: radialMenuActions) } - @ViewBuilder private func actionButton( action: WindowAction? = nil, isActive: Bool, diff --git a/Loop/Stashing/StashManager.swift b/Loop/Stashing/StashManager.swift index 7cb853bc..eb97eb31 100644 --- a/Loop/Stashing/StashManager.swift +++ b/Loop/Stashing/StashManager.swift @@ -35,6 +35,7 @@ import SwiftUI /// /// ## Considerations: /// - Currently supports only one revealed window at a time. +@Loggable final class StashManager { static let shared = StashManager() private init() {} @@ -72,7 +73,7 @@ final class StashManager { private var lastRevealTime: [CGWindowID: Date] = [:] private var mouseMonitor: PassiveEventMonitor? private var frontmostAppMonitor: Task<(), Never>? - private var mouseMoveWorkItem: DispatchWorkItem? + private var mouseMovedTask: Task<(), Never>? // MARK: - Public methods @@ -85,7 +86,7 @@ final class StashManager { restoreAllStashedWindows(animate: false) } - func onWindowDragged(_ id: CGWindowID) { + func onWindowManipulated(_ id: CGWindowID) { unmanage(windowID: id) } @@ -115,7 +116,7 @@ final class StashManager { return false } - Log.info("Intercepting window action for stashed window \(stashedWindow.window.description)", category: .stashManager) + log.info("Intercepting window action for stashed window \(stashedWindow.window.description)") Task { if store.isWindowRevealed(stashedWindow.window.cgWindowID) { @@ -133,7 +134,7 @@ final class StashManager { } deinit { - mouseMoveWorkItem?.cancel() + mouseMovedTask?.cancel() stopListeningToRevealTriggers() restoreAllStashedWindows(animate: false) } @@ -159,10 +160,11 @@ extension StashManager { // the leftmost screen for `.left` or the rightmost screen for `.right`. If the window's current screen differs from the target screen, // the function recursively adjusts the window's position to ensure it is stashed on the correct screen. if let screenForEdge = getScreenForEdge(currentScreen: screen, edge: edge), screen != screenForEdge { - Log.info("Attempting to stash window on the \(edge.debugDescription) edge, but \(screen.localizedName) is not the \(edge.debugDescription)most screen. Redirecting to the correct screen.", category: .stashManager) + log.info("Attempting to stash window on the \(edge.debugDescription) edge, but \(screen.localizedName) is not the \(edge.debugDescription)most screen. Redirecting to the correct screen.") onWindowResized(action: action, window: window, screen: screenForEdge) } else { let windowToStash = StashedWindowInfo(window: window, screen: screen, action: action) + Task { await stash(windowToStash) } @@ -185,7 +187,7 @@ extension StashManager { // without adding its id to `store.revealed`. Whe need to add it back so the hide animation can be triggered. if isManaged(window.cgWindowID) { // If the window frame is fully on screen while the window ID is not in the `store.reveal` set, we add it. - let isWindowFullyOnScreen = screen.safeScreenFrame.contains(window.frame) + let isWindowFullyOnScreen = screen.cgSafeScreenFrame.contains(window.frame) if isWindowFullyOnScreen, !store.isWindowRevealed(window.cgWindowID) { store.markWindowAsRevealed(window.cgWindowID) @@ -204,7 +206,7 @@ extension StashManager { /// Add the given `StashWindow` to the list of monitored windows, move the window to the stashed area /// and start mouse moved listener if needed. private func stash(_ windowToStash: StashedWindowInfo) async { - Log.info("stash \(windowToStash.window.description)", category: .stashManager) + log.info("stash \(windowToStash.window.description)") unstashOverlappingWindows(windowToStash) @@ -224,14 +226,14 @@ extension StashManager { /// Stop monitoring the window. If `resetFrame` is true, the window will be moved to its initial frame. private func unstash(_ window: StashedWindowInfo, resetFrame: Bool, resetFrameAnimated: Bool) { - Log.info("unstash \(window.window.description)", category: .stashManager) + log.info("unstash \(window.window.description)") if resetFrame { let action = WindowAction(.initialFrame) - let initialFrame = action.getFrame( + let initialFrame = WindowFrameResolver.getFrame( + for: action, window: window.window, - bounds: window.screen.safeScreenFrame, - screen: window.screen + bounds: window.screen.cgSafeScreenFrame ) if resetFrameAnimated { @@ -293,7 +295,7 @@ private extension StashManager { } store.markWindowAsRevealed(window.window.cgWindowID) - Log.info("revealWindow \(window.window.description)", category: .stashManager) + log.info("revealWindow \(window.window.description)") } /// Hides a stashed window by moving it to its stashed frame. @@ -316,7 +318,7 @@ private extension StashManager { } store.markWindowAsHidden(window.window.cgWindowID) - Log.info("hideWindow \(window.window.description)", category: .stashManager) + log.info("hideWindow \(window.window.description)") } /// Checks if the window reveal / hide should be throttled based on the last reveal time. @@ -350,7 +352,7 @@ private extension StashManager { } if let focusWindow { - Log.info("Focusing another window on the same screen: \(focusWindow.description).", category: .stashManager) + log.info("Focusing another window on the same screen: \(focusWindow.description).") Task { @MainActor in focusWindow.focus() } @@ -364,7 +366,7 @@ private extension StashManager { func startListeningToRevealTriggers() { guard mouseMonitor == nil else { return } - Log.info("Listening for reveal triggers…", category: .stashManager) + log.info("Listening for reveal triggers…") let monitor = PassiveEventMonitor( events: [ @@ -391,7 +393,7 @@ private extension StashManager { func stopListeningToRevealTriggers() { guard mouseMonitor != nil else { return } - Log.info("Stopping listening for reveal triggers…", category: .stashManager) + log.info("Stopping listening for reveal triggers…") mouseMonitor?.stop() mouseMonitor = nil @@ -401,34 +403,37 @@ private extension StashManager { /// Handles mouse movement events with a debounce to avoid excessive processing. private func handleMouseMoved(cgEvent _: CGEvent) { - Task { @MainActor in - mouseMoveWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in self?.processMouseMovement() } - mouseMoveWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + mouseMovedDebounceInterval, execute: workItem) + mouseMovedTask?.cancel() + + mouseMovedTask = Task { + try? await Task.sleep(for: .seconds(mouseMovedDebounceInterval)) + + guard !Task.isCancelled else { + return + } + + await processMouseMovement() } } /// Handles mouse movement events to reveal or hide stashed windows. - private func processMouseMovement() { - Task { - let mouseLocation = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) - let windows = getZSortedStashedWindows() - - for window in windows { - if store.isWindowRevealed(window.window.cgWindowID) { - if shouldHide(window: window, for: mouseLocation) { - await hideWindow(window) - } else { - break - } - } else if isMouseOverStashed(window: window, location: mouseLocation) { - // The cursor is over the topmost stashed window that should be revealed - // revealWindow will move it on screen and hide any other revealed window. - await revealWindow(window) - // Only one window can be revealed at a time, so stop processing. + private func processMouseMovement() async { + let mouseLocation = NSEvent.mouseLocation.flipY(screen: NSScreen.screens[0]) + let windows = getZSortedStashedWindows() + + for window in windows { + if store.isWindowRevealed(window.window.cgWindowID) { + if shouldHide(window: window, for: mouseLocation) { + await hideWindow(window) + } else { break } + } else if isMouseOverStashed(window: window, location: mouseLocation) { + // The cursor is over the topmost stashed window that should be revealed + // revealWindow will move it on screen and hide any other revealed window. + await revealWindow(window) + // Only one window can be revealed at a time, so stop processing. + break } } } @@ -514,14 +519,14 @@ private extension StashManager { // Trying to store windowToStash in the same place as stashedWindow. // No need for frame comparaison, it will always overlap. if stashedWindow.action.id == windowToStash.action.id, stashedWindow.screen.isSameScreen(windowToStash.screen) { - Log.info("Trying to stash a window in the same place as another one. Replacing…", category: .stashManager) + log.info("Trying to stash a window in the same place as another one. Replacing…") unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) } else { let currentFrame = stashedWindow.computeStashedFrame(peekSize: stashedWindowVisiblePadding) let tolerance = minimumVisibleHeightToKeepWindowStacked if !isThereEnoughNonOverlappingSpace(between: newFrame, and: currentFrame, tolerance: tolerance) { - Log.info("Trying to stash a window overlapping another one. Replacing…", category: .stashManager) + log.info("Trying to stash a window overlapping another one. Replacing…") unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) } } diff --git a/Loop/Stashing/StashedWindowInfo.swift b/Loop/Stashing/StashedWindowInfo.swift index cd747148..8f05cbed 100644 --- a/Loop/Stashing/StashedWindowInfo.swift +++ b/Loop/Stashing/StashedWindowInfo.swift @@ -9,6 +9,7 @@ import Foundation import Scribe import SwiftUI +@Loggable struct StashedWindowInfo: Equatable { let window: Window let screen: NSScreen @@ -18,8 +19,8 @@ struct StashedWindowInfo: Equatable { /// Computes the frame for a stashed window. func computeStashedFrame(peekSize: CGFloat, maxPeekPercent: CGFloat = 0.2) -> CGRect { - let bounds = screen.safeScreenFrame - var frame = action.getFrame(window: window, bounds: bounds, screen: screen) + let bounds = screen.cgSafeScreenFrame + var frame = WindowFrameResolver.getFrame(for: action, window: window, bounds: bounds) let minPeekSize: CGFloat = 1 let maxPeekSize = frame.width * maxPeekPercent @@ -31,13 +32,15 @@ struct StashedWindowInfo: Equatable { case .right: frame.origin.x = bounds.maxX - clampedPeekSize case .none: - Log.warn("Trying to compute the stash frame for a non-stash related action.", category: .stashedWindow) + log.warn("Trying to compute the stash frame for a non-stash related action.") } return frame } func computeRevealedFrame() -> CGRect { - action.getFrame(window: window, bounds: screen.safeScreenFrame, screen: screen) + let context = ResizeContext(window: window, screen: screen) + context.setAction(to: action, parent: nil) + return context.getTargetFrame().padded } } diff --git a/Loop/Stashing/StashedWindowStore.swift b/Loop/Stashing/StashedWindowStore.swift index 50e3d56e..867fb25b 100644 --- a/Loop/Stashing/StashedWindowStore.swift +++ b/Loop/Stashing/StashedWindowStore.swift @@ -16,6 +16,7 @@ protocol StashedWindowsStoreDelegate: AnyObject { /// Keep the stashed windows and the revealed window ids both in memory and in Defaults. /// Restore windows stashed from a previous session. +@Loggable final class StashedWindowsStore { weak var delegate: StashedWindowsStoreDelegate? @@ -57,7 +58,7 @@ final class StashedWindowsStore { stashed[cgWindowID] = window Defaults[.stashManagerStashedWindows] = stashed.mapValues(\.action) - Log.info("Persisted stashed windows (count: \(stashed.count))", category: .stashManager) + log.info("Persisted stashed windows (count: \(stashed.count))") } // MARK: Private methods @@ -78,12 +79,12 @@ final class StashedWindowsStore { if !restoredStashedWindows.isEmpty { stashed = restoredStashedWindows - Log.info("\(restoredStashedWindows.count) stashed window restored.", category: .stashedWindowsStore) + log.info("\(restoredStashedWindows.count) stashed window restored.") delegate?.onStashedWindowsRestored() } if !failedToRestore.isEmpty { - Log.error("Failed to restore \(failedToRestore.count) window(s).", category: .stashedWindowsStore) + log.error("Failed to restore \(failedToRestore.count) window(s).") // Window restoration usually fail because the window is on another space and will // not be returned by WindowEngine.windowList until the user goes to that space. @@ -97,7 +98,7 @@ final class StashedWindowsStore { let windows = WindowUtility.windowList() var restored = 0 - Log.info("Space changed. Attempting to restore windows.", category: .stashedWindowsStore) + log.info("Space changed. Attempting to restore windows.") for (windowId, direction) in failedToRestore { guard let stashedWindow = getStashedWindow(for: windowId, in: windows, action: direction) else { diff --git a/Loop/Updater/Updater.swift b/Loop/Updater/Updater.swift index b9ca34e4..0519db2b 100755 --- a/Loop/Updater/Updater.swift +++ b/Loop/Updater/Updater.swift @@ -99,7 +99,7 @@ final class Updater: ObservableObject { shouldAutoPresentUpdateWindow = true - /// If the updater has requested that the update window be presented for over 6 hours, automatically present it. + // If the updater has requested that the update window be presented for over 6 hours, automatically present it. autoPresentUpdateWindowTask = Task { log.info("Will automatically present update window in 6 hours if there is no activity") @@ -173,7 +173,7 @@ final class Updater: ObservableObject { shouldAutoPresentUpdateWindow = false } - // Pulls the latest release information from GitHub and updates the app state accordingly. + /// Pulls the latest release information from GitHub and updates the app state accordingly. func fetchLatestInfo(bypassUpdatesEnabled: Bool = false) async { // Don't run update checks while actively downloading if downloader.isDownloading == true { @@ -257,7 +257,7 @@ final class Updater: ObservableObject { log.ui("Update window shown") } - // Downloads the update from GitHub and installs it + /// Downloads the update from GitHub and installs it func downloadAndInstallUpdate() async throws { guard let manifest = updateManifest else { progressBar = 0 diff --git a/Loop/Updater/UpdaterModels.swift b/Loop/Updater/UpdaterModels.swift index 8bd091ab..76ada67f 100644 --- a/Loop/Updater/UpdaterModels.swift +++ b/Loop/Updater/UpdaterModels.swift @@ -144,7 +144,7 @@ enum UpdateError: LocalizedError, Sendable { } } - // Convenience constructors + /// Convenience constructors static func httpError(_ response: HTTPURLResponse) -> UpdateError { .http(response.statusCode) } diff --git a/Loop/Updater/Utilities/BackupManager.swift b/Loop/Updater/Utilities/BackupManager.swift index 55d84d55..1dc18bd9 100644 --- a/Loop/Updater/Utilities/BackupManager.swift +++ b/Loop/Updater/Utilities/BackupManager.swift @@ -129,10 +129,11 @@ actor BackupManager { options: [.skipsHiddenFiles] ) else { return 0 } - return Int64(enumerator - .compactMap { $0 as? URL } - .compactMap { try? $0.resourceValues(forKeys: [.fileSizeKey]).fileSize } - .reduce(0, +) + return Int64( + enumerator + .compactMap { $0 as? URL } + .compactMap { try? $0.resourceValues(forKeys: [.fileSizeKey]).fileSize } + .reduce(0, +) ) } } diff --git a/Loop/Updater/Utilities/ChangelogParser.swift b/Loop/Updater/Utilities/ChangelogParser.swift index d51365e8..a1c4e8f4 100644 --- a/Loop/Updater/Utilities/ChangelogParser.swift +++ b/Loop/Updater/Utilities/ChangelogParser.swift @@ -173,17 +173,17 @@ private extension Character { var hasEmojiPresentationAsDefault: Bool { let scalars = unicodeScalars - /// Must contain at least one emoji-capable scalar + // Must contain at least one emoji-capable scalar guard scalars.contains(where: \.properties.isEmoji) else { return false } - /// If any scalar defaults to emoji, it's an emoji + // If any scalar defaults to emoji, it's an emoji if scalars.contains(where: \.properties.isEmojiPresentation) { return true } - /// If it contains the emojification codepoint (U+FE0F, Variation Selector-16) + // If it contains the emojification codepoint (U+FE0F, Variation Selector-16) if scalars.contains(where: { $0.value == 0xFE0F }) { return true } diff --git a/Loop/Updater/Utilities/ChecksumVerifier.swift b/Loop/Updater/Utilities/ChecksumVerifier.swift index af3b0d37..468bf818 100644 --- a/Loop/Updater/Utilities/ChecksumVerifier.swift +++ b/Loop/Updater/Utilities/ChecksumVerifier.swift @@ -12,29 +12,29 @@ import Scribe @Loggable(style: .static) enum ChecksumVerifier { static func verifyFile(_ fileURL: URL, expectedChecksum: String) async throws { - Log.debug("Starting checksum calculation for file: \(fileURL.path)") + log.debug("Starting checksum calculation for file: \(fileURL.path)") let actualChecksum = try await calculateSHA256(fileURL) let isMatch = actualChecksum == expectedChecksum guard isMatch else { - Log.error("Checksum mismatch - File: \(fileURL.path)") + log.error("Checksum mismatch - File: \(fileURL.path)") throw UpdateError.checksumMismatch } - Log.debug("Checksum verification completed successfully") + log.debug("Checksum verification completed successfully") } @concurrent private static func calculateSHA256(_ fileURL: URL) async throws -> String { - Log.debug("Calculating SHA256 for file - File: \(fileURL.path), Exists: \(FileManager.default.fileExists(atPath: fileURL.path))") + log.debug("Calculating SHA256 for file - File: \(fileURL.path), Exists: \(FileManager.default.fileExists(atPath: fileURL.path))") let data = try Data(contentsOf: fileURL) - Log.debug("File data loaded - Size: \(data.count) bytes, File: \(fileURL.lastPathComponent)") + log.debug("File data loaded - Size: \(data.count) bytes, File: \(fileURL.lastPathComponent)") let digest = SHA256.hash(data: data) let checksum = digest.compactMap { String(format: "%02x", $0) }.joined() - Log.debug("SHA256 calculation complete - Checksum: \(checksum), File: \(fileURL.lastPathComponent)") + log.debug("SHA256 calculation complete - Checksum: \(checksum), File: \(fileURL.lastPathComponent)") return checksum } } diff --git a/Loop/Updater/Utilities/ZipExtractor.swift b/Loop/Updater/Utilities/ZipExtractor.swift index bd64e28c..067c0559 100644 --- a/Loop/Updater/Utilities/ZipExtractor.swift +++ b/Loop/Updater/Utilities/ZipExtractor.swift @@ -78,9 +78,7 @@ enum ZipExtractor { ) throws { log.info("Extracting ZIP archive: \(zipURL.lastPathComponent)") - guard let archive = try Archive(url: zipURL, accessMode: .read) else { - throw createError("Could not open ZIP archive", zipURL: zipURL) - } + let archive = try Archive(url: zipURL, accessMode: .read) for entry in archive where !entry.path.contains(/__MACOSX/) { try cancellationCheck?() diff --git a/Loop/Updater/Views/UpdateView.swift b/Loop/Updater/Views/UpdateView.swift index 5b5201cf..aa40d54a 100644 --- a/Loop/Updater/Views/UpdateView.swift +++ b/Loop/Updater/Views/UpdateView.swift @@ -69,7 +69,6 @@ struct UpdateView: View { .frame(width: 500, height: 480) } - @ViewBuilder private func theLoopTimesView() -> some View { ZStack { if colorScheme == .dark { @@ -132,7 +131,6 @@ struct UpdateView: View { } } - @ViewBuilder private func versionChangeText() -> some View { HStack { let currentVersion = VersionDisplay.current diff --git a/Loop/Utilities/AccessibilityManager.swift b/Loop/Utilities/AccessibilityManager.swift index 195d27ac..cfd29211 100644 --- a/Loop/Utilities/AccessibilityManager.swift +++ b/Loop/Utilities/AccessibilityManager.swift @@ -9,12 +9,12 @@ import Defaults import SwiftUI /// Stores and manages the accessibility permission state for Loop. +@MainActor final class AccessibilityManager { static let shared: AccessibilityManager = .init() private var permissionCheckerTask: Task<(), Never>! - @MainActor private var continuations: [UUID: AsyncStream.Continuation] = [:] private(set) var isGranted: Bool @@ -28,13 +28,13 @@ final class AccessibilityManager { .notifications(named: .AXPermissionsChanged) for await _ in notifications { - /// It seems like the notification is sent immediately after a state change, sometimes before the actual - /// reading from `AXIsProcessTrustedWithOptions` is updated. - /// So sleep for 250 milliseconds (this is generous, but just to ensure that the reading will be correct). + // It seems like the notification is sent immediately after a state change, sometimes before the actual + // reading from `AXIsProcessTrustedWithOptions` is updated. + // So sleep for 250 milliseconds (this is generous, but just to ensure that the reading will be correct). try? await Task.sleep(for: .milliseconds(250)) let status = Self.getStatus() - await self.yield(status) + self.yield(status) } } } @@ -55,7 +55,6 @@ final class AccessibilityManager { /// Stream new changes to Loop's accessibility permissions. /// - Parameter initial: whether to send an initial value corresponding to Loop's current permissions /// - Returns: an AsyncStream. - @MainActor func stream(initial: Bool = true) -> AsyncStream { AsyncStream { continuation in let id = UUID() @@ -66,8 +65,9 @@ final class AccessibilityManager { } continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { @MainActor in - guard let self else { return } self.continuations[id] = nil } } diff --git a/Loop/Utilities/AnimationConfiguration.swift b/Loop/Utilities/AnimationConfiguration.swift index a53e1161..78c6dd19 100644 --- a/Loop/Utilities/AnimationConfiguration.swift +++ b/Loop/Utilities/AnimationConfiguration.swift @@ -34,49 +34,21 @@ enum AnimationConfiguration: Int, Defaults.Serializable, CaseIterable, Identifia // MARK: Preview Window - var previewTimingFunction: CAMediaTimingFunction? { + var previewWindow: Animation? { switch self { case .fluid: - CAMediaTimingFunction(controlPoints: 0, 0.26, 0.45, 1) + .timingCurve(0, 0.26, 0.45, 1, duration: 0.325) case .relaxed: - CAMediaTimingFunction(controlPoints: 0.15, 0.8, 0.46, 1) + .timingCurve(0.15, 0.8, 0.46, 1, duration: 0.3) case .snappy: - CAMediaTimingFunction(controlPoints: 0.22, 1, 0.47, 1) + .timingCurve(0.22, 1, 0.47, 1, duration: 0.25) case .brisk: - CAMediaTimingFunction(controlPoints: 0.25, 1, 0.48, 1) - case .instant: + .timingCurve(0.25, 1, 0.48, 1, duration: 0.15) + default: nil } } - var previewTimingFunctionSwiftUI: Animation? { - guard let points = previewTimingFunction?.controlPoints else { - return nil - } - return .timingCurve( - points.0.x, - points.0.y, - points.1.x, - points.1.y, - duration: previewTimingDuration - ) - } - - var previewTimingDuration: TimeInterval { - switch self { - case .fluid: - 0.325 - case .relaxed: - 0.3 - case .snappy: - 0.25 - case .brisk: - 0.15 - case .instant: - 0 - } - } - // MARK: Radial Menu var radialMenuSize: Animation { @@ -106,21 +78,3 @@ enum AnimationConfiguration: Int, Defaults.Serializable, CaseIterable, Identifia self != .instant } } - -private extension CAMediaTimingFunction { - var controlPoints: (CGPoint, CGPoint) { - var c1: [Float] = [0, 0] - var c2: [Float] = [0, 0] - - // 0 and 3 are the start/end points, so grab the center two points - getControlPoint(at: 1, values: &c1) - getControlPoint(at: 2, values: &c2) - - let c1x = CGFloat(c1[0]) - let c1y = CGFloat(c1[1]) - let c2x = CGFloat(c2[0]) - let c2y = CGFloat(c2[1]) - - return (CGPoint(x: c1x, y: c1y), CGPoint(x: c2x, y: c2y)) - } -} diff --git a/Loop/Utilities/CustomTextField.swift b/Loop/Utilities/CustomTextField.swift index 0cae2196..24b55164 100644 --- a/Loop/Utilities/CustomTextField.swift +++ b/Loop/Utilities/CustomTextField.swift @@ -7,7 +7,7 @@ import SwiftUI -// Custom TextField that will allow for auto-focus to happen correctly when the popover is shown. +/// Custom TextField that will allow for auto-focus to happen correctly when the popover is shown. struct CustomTextField: NSViewRepresentable { @Binding var text: String let placeholder: String @@ -31,10 +31,6 @@ struct CustomTextField: NSViewRepresentable { // Set the target-action for text changes textField.delegate = context.coordinator - DispatchQueue.main.async { - textField.becomeFirstResponder() - } - return textField } diff --git a/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift b/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift index 4c68b1bc..e76bf969 100644 --- a/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/ActiveEventMonitor.swift @@ -80,7 +80,7 @@ final class ActiveEventMonitor: BaseEventTapMonitor { ) { setupRunLoopSource(eventTap: eventTap) } else { - Log.info("Failed to create event tap", category: .activeEventMonitor) + log.info("Failed to create event tap") } } diff --git a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift index 3c6d1961..90470727 100644 --- a/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift +++ b/Loop/Utilities/Event Monitoring/BaseEventTapMonitor.swift @@ -10,7 +10,8 @@ import Foundation import Scribe /// Base class to share common functionality. DO NOT USE DIRECTLY! -class BaseEventTapMonitor: Identifiable, Equatable { +@Loggable +class BaseEventTapMonitor: EventMonitorProtocol, Identifiable, Equatable { let id = UUID() private var eventTap: CFMachPort? @@ -36,7 +37,7 @@ class BaseEventTapMonitor: Identifiable, Equatable { } func setupRunLoopSource(eventTap: CFMachPort) { - /// Runloop is already running here. In the future, we can investigate running the mach port on another thread. + // Runloop is already running here. In the future, we can investigate running the mach port on another thread. let runLoop = CFRunLoopGetMain() if let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) { @@ -50,7 +51,7 @@ class BaseEventTapMonitor: Identifiable, Equatable { func start() { guard let eventTap else { return } - Log.info("Starting BaseEventTapMonitor with ID \(id)", category: .baseEventTapMonitor) + log.info("Starting BaseEventTapMonitor with ID \(id)") CGEvent.tapEnable(tap: eventTap, enable: true) isEnabled = true @@ -59,7 +60,7 @@ class BaseEventTapMonitor: Identifiable, Equatable { func stop() { guard let eventTap else { return } - Log.info("Stopping BaseEventTapMonitor with ID \(id)", category: .baseEventTapMonitor) + log.info("Stopping BaseEventTapMonitor with ID \(id)") CGEvent.tapEnable(tap: eventTap, enable: false) isEnabled = false diff --git a/Loop/Utilities/Event Monitoring/EventMonitorProtocol.swift b/Loop/Utilities/Event Monitoring/EventMonitorProtocol.swift new file mode 100644 index 00000000..4c2f15f2 --- /dev/null +++ b/Loop/Utilities/Event Monitoring/EventMonitorProtocol.swift @@ -0,0 +1,13 @@ +// +// EventMonitorProtocol.swift +// Loop +// +// Created by Kai Azim on 2026-01-18. +// + +import Foundation + +protocol EventMonitorProtocol { + func start() + func stop() +} diff --git a/Loop/Utilities/Event Monitoring/LocalEventMonitor.swift b/Loop/Utilities/Event Monitoring/LocalEventMonitor.swift index 12f44b81..ca4f6a2b 100644 --- a/Loop/Utilities/Event Monitoring/LocalEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/LocalEventMonitor.swift @@ -8,7 +8,8 @@ import Cocoa import Scribe -final class LocalEventMonitor: Identifiable, Equatable { +@Loggable +final class LocalEventMonitor: EventMonitorProtocol, Identifiable, Equatable { let id = UUID() private var localEventMonitor: Any? @@ -41,7 +42,7 @@ final class LocalEventMonitor: Identifiable, Equatable { func start() { guard !isEnabled else { return } - Log.info("Starting LocalEventMonitor with ID \(id)", category: .localEventMonitor) + log.info("Starting LocalEventMonitor with ID \(id)") localEventMonitor = NSEvent.addLocalMonitorForEvents( matching: eventTypeMask, @@ -56,7 +57,7 @@ final class LocalEventMonitor: Identifiable, Equatable { func stop() { guard isEnabled else { return } - Log.info("Stopping LocalEventMonitor with ID \(id)", category: .localEventMonitor) + log.info("Stopping LocalEventMonitor with ID \(id)") if let localEventMonitor { NSEvent.removeMonitor(localEventMonitor) diff --git a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift index de26b96f..f3943c48 100644 --- a/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift +++ b/Loop/Utilities/Event Monitoring/PassiveEventMonitor.swift @@ -43,7 +43,7 @@ final class PassiveEventMonitor: BaseEventTapMonitor { } // Call the callback but always pass the unmodified event through - observer.handleEvent(event: event) + observer.eventCallback(event) return Unmanaged.passUnretained(event) } let userInfo = Unmanaged.passUnretained(self).toOpaque() @@ -58,13 +58,7 @@ final class PassiveEventMonitor: BaseEventTapMonitor { ) { setupRunLoopSource(eventTap: eventTap) } else { - Log.info("Failed to create event tap", category: .passiveEventMonitor) - } - } - - private func handleEvent(event: CGEvent) { - Task { - eventCallback(event) + log.info("Failed to create event tap") } } } diff --git a/Loop/Utilities/PaddingModel.swift b/Loop/Utilities/PaddingModel.swift deleted file mode 100644 index e85af1d7..00000000 --- a/Loop/Utilities/PaddingModel.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// PaddingModel.swift -// Loop -// -// Created by Kai Azim on 2024-02-01. -// - -import Defaults -import SwiftUI - -struct PaddingModel: Codable, Defaults.Serializable, Hashable { - var window: CGFloat - var externalBar: CGFloat - var top: CGFloat - var bottom: CGFloat - var right: CGFloat - var left: CGFloat - - var configureScreenPadding: Bool - - var totalTopPadding: CGFloat { - top + externalBar - } - - static var zero = PaddingModel( - window: 0, - externalBar: 0, - top: 0, - bottom: 0, - right: 0, - left: 0, - configureScreenPadding: false - ) - - var allEqual: Bool { - window == top && window == bottom && window == right && window == left - } - - func apply(onScreenFrame initial: CGRect) -> CGRect { - initial - .padding(.leading, left) - .padding(.trailing, right) - .padding(.bottom, bottom) - .padding(.top, totalTopPadding) - } -} diff --git a/Loop/Utilities/PaddingSettings.swift b/Loop/Utilities/PaddingSettings.swift deleted file mode 100644 index 50155e1f..00000000 --- a/Loop/Utilities/PaddingSettings.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// PaddingSettings.swift -// Loop -// -// Created by Kai Azim on 2025-08-29. -// - -import AppKit -import Defaults - -enum PaddingSettings { - static func configuredPadding(for screen: NSScreen?) -> PaddingModel { - if #available(macOS 15, *), Defaults[.useSystemWindowManagerWhenAvailable] { - guard SystemWindowManager.MoveAndResize.enablePadding else { - return .zero - } - - let padding = SystemWindowManager.MoveAndResize.padding - - return PaddingModel( - window: padding, - externalBar: 0, - top: padding, - bottom: padding, - right: padding, - left: padding, - configureScreenPadding: false - ) - } else { - let respectsPaddingThreshold = if let screen { - Defaults[.paddingMinimumScreenSize] == 0 || screen.diagonalSize > Defaults[.paddingMinimumScreenSize] - } else { - true - } - let enablePadding = Defaults[.enablePadding] && respectsPaddingThreshold - - return enablePadding ? Defaults[.padding] : .zero - } - } -} diff --git a/Loop/Utilities/PickerList.swift b/Loop/Utilities/PickerList.swift index b392565f..d9d88d81 100644 --- a/Loop/Utilities/PickerList.swift +++ b/Loop/Utilities/PickerList.swift @@ -48,7 +48,6 @@ struct PickerList: View where Content: View, V: Hashable, V: Identif } } - @ViewBuilder private func contentStack(reader: ScrollViewProxy) -> some View { VStack(alignment: .leading, spacing: 4) { if searchResults.isEmpty { @@ -132,18 +131,18 @@ struct PickerList: View where Content: View, V: Hashable, V: Identif let currentIndex = items.firstIndex(where: { $0 == arrowSelection }) ?? (increment ? -1 : items.count) let nextIndex = currentIndex + (increment ? 1 : -1) - /// Ensure nextIndex is valid + // Ensure nextIndex is valid guard nextIndex >= 0, nextIndex < items.count else { - Log.error("Invalid nextIndex: \(nextIndex), items count: \(items.count)", category: .pickerView) + Log.error("Invalid nextIndex: \(nextIndex), items count: \(items.count)", category: .pickerList) return } let newSelection = items[nextIndex] arrowSelection = newSelection - /// Only scroll if the selection is valid and not nil + // Only scroll if the selection is valid and not nil guard let validSelection = arrowSelection else { - Log.info("arrowSelection is nil, skipping scroll", category: .pickerView) + Log.info("arrowSelection is nil, skipping scroll", category: .pickerList) return } @@ -151,6 +150,10 @@ struct PickerList: View where Content: View, V: Hashable, V: Identif } } +extension LogCategory { + static let pickerList = LogCategory("PickerList") +} + struct PopoverPickerItem: View where Content: View, V: Hashable { @EnvironmentObject private var popover: LuminarePopupPanel @Environment(\.luminareAnimationFast) private var animationFast diff --git a/Loop/Utilities/VisualEffectView.swift b/Loop/Utilities/VisualEffectView.swift index f6ff90f4..7c1bed5a 100644 --- a/Loop/Utilities/VisualEffectView.swift +++ b/Loop/Utilities/VisualEffectView.swift @@ -7,7 +7,7 @@ import SwiftUI -// SwiftUI view for NSVisualEffect +/// SwiftUI view for NSVisualEffect struct VisualEffectView: NSViewRepresentable { let material: NSVisualEffectView.Material let blendingMode: NSVisualEffectView.BlendingMode diff --git a/Loop/Window Action Indicators/ActivePanel.swift b/Loop/Window Action Indicators/ActivePanel.swift index 3f79d2d6..c06096a8 100644 --- a/Loop/Window Action Indicators/ActivePanel.swift +++ b/Loop/Window Action Indicators/ActivePanel.swift @@ -7,6 +7,7 @@ import AppKit +@MainActor final class ActivePanel: NSPanel { @objc dynamic var hasKeyAppearance: Bool { true diff --git a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift deleted file mode 100644 index ea87c576..00000000 --- a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// LuminarePreviewView.swift -// Loop -// -// Created by Kai Azim on 2024-05-28. -// - -import Defaults -import Luminare -import SwiftUI - -struct LuminarePreviewView: View { - @Environment(\.luminareAnimation) private var luminareAnimation - @Environment(\.appearsActive) private var appearsActive - @EnvironmentObject private var windowModel: SettingsWindowManager - @ObservedObject private var accentColorController: AccentColorController = .shared - - @State var actionRect: CGRect = .zero - - @Default(.previewPadding) var previewPadding - @Default(.padding) var padding - @Default(.previewCornerRadius) var previewCornerRadius - @Default(.previewBorderThickness) var previewBorderThickness - @Default(.animationConfiguration) var animationConfiguration - - var body: some View { - GeometryReader { geo in - PreviewView(viewModel: PreviewViewModel(window: nil)) - .frame(width: actionRect.width, height: actionRect.height) - .offset(x: actionRect.minX, y: actionRect.minY) - .opacity(actionRect.size.area == .zero ? 0 : 1) - .onChange( - of: windowModel.previewedAction, - initial: true - ) { newAction in - let newActionRect: CGRect = if newAction.willManipulateExistingWindowFrame { - .init( - x: geo.size.width / 2, - y: geo.size.height / 2, - width: 0, - height: 0 - ) - } else { - newAction.getFrame( - window: nil, - bounds: .init(origin: .zero, size: geo.size), - isPreview: true - ) - } - - if actionRect == .zero { - actionRect = newActionRect - } else { - withAnimation(animationConfiguration.previewTimingFunctionSwiftUI) { - actionRect = newActionRect - } - } - } - } - } -} diff --git a/Loop/Window Action Indicators/Preview Window/PreviewController.swift b/Loop/Window Action Indicators/Preview Window/PreviewController.swift index 26f4017e..4b400d9b 100644 --- a/Loop/Window Action Indicators/Preview Window/PreviewController.swift +++ b/Loop/Window Action Indicators/Preview Window/PreviewController.swift @@ -9,191 +9,66 @@ import Defaults import Scribe import SwiftUI -final class PreviewController { +@Loggable +@MainActor +final class PreviewController: WindowActionIndicator { + private let viewModel: PreviewViewModel = .init(isSettingsPreview: false) private var controller: NSWindowController? - private var viewModel: PreviewViewModel? - private var screen: NSScreen? - private var window: Window? - - func open( - screen: NSScreen, - window: Window?, - startingAction: WindowAction? - ) { - if let windowController = controller { - windowController.window?.orderFrontRegardless() + func open(context: ResizeContext) { + guard let screen = context.screen else { + log.error("Screen not defined in context") return } - let viewModel = PreviewViewModel(window: window) - self.viewModel = viewModel + if let window = controller?.window { + var didScreenSwitch = false + + // Move panel to new screen if screen changed + if window.screen != screen { + window.setFrame(screen.frame, display: true) + didScreenSwitch = true + } + window.orderFrontRegardless() + viewModel.updateContext(with: context, isScreenSwitch: didScreenSwitch) + + return + } - self.screen = screen - self.window = window + defer { viewModel.updateContext(with: context, isScreenSwitch: false) } let panel = ActivePanel( contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, - defer: true, - screen: NSApp.keyWindow?.screen + defer: true ) - panel.alphaValue = 0 + controller = .init(window: panel) + + panel.ignoresMouseEvents = true + panel.collectionBehavior = .canJoinAllSpaces + panel.hasShadow = false panel.backgroundColor = .clear - panel.setFrame(NSRect(origin: screen.stageStripFreeFrame.center, size: .zero), display: true) - // This ensures that this is below the radial menu panel.level = NSWindow.Level(NSWindow.Level.screenSaver.rawValue - 1) panel.contentView = NSHostingView(rootView: PreviewView(viewModel: viewModel)) - panel.collectionBehavior = .canJoinAllSpaces - panel.hasShadow = false - panel.ignoresMouseEvents = true + panel.setFrame(screen.frame, display: true) + panel.orderFrontRegardless() - controller = .init(window: panel) - if let action = startingAction { - setAction(to: action) - } + log.ui("Initialized controller") } func close() { guard let windowController = controller else { return } - let window = windowController.window controller = nil - if window?.alphaValue == 0 { - windowController.close() - return - } - - let animationConfiguration = Defaults[.animationConfiguration] - if let timingFunction = animationConfiguration.previewTimingFunction { - window?.alphaValue = 1 - - NSAnimationContext.runAnimationGroup { context in - context.timingFunction = timingFunction - context.duration = animationConfiguration.previewTimingDuration * 2 - window?.animator().alphaValue = 0 - } completionHandler: { - windowController.close() - } - } else { + Task { + viewModel.setIsShown(false) + try? await Task.sleep(for: .seconds(0.4)) + windowController.window?.orderOut(nil) windowController.close() - } - } - func setWindow(to newWindow: Window) { - window = newWindow - viewModel?.setWindow(to: newWindow) - } - - func setScreen(to newScreen: NSScreen) { - guard - controller != nil, // Ensures that the preview window is open - screen != newScreen - else { - return + log.ui("Controller closed") } - - close() - open(screen: newScreen, window: window, startingAction: nil) - - Log.info("Changed preview window's screen", category: .previewController) - } - - func setAction(to newAction: WindowAction) { - guard - let windowController = controller, - let screen, - !newAction.direction.willChangeScreen, - newAction.direction != .cycle - else { - return - } - - /// Check screen bounds - Log.info("Screen frame: \(screen.frame.debugDescription)", category: .previewController) - Log.info("Screen safeScreenFrame: \(screen.safeScreenFrame.debugDescription)", category: .previewController) - - // Validate screen bounds before proceeding - guard screen.safeScreenFrame.isFinite else { - Log.error("Invalid screen bounds detected", category: .previewController) - return - } - - var targetWindowFrame = newAction.getFrame( - window: window, - bounds: screen.safeScreenFrame, - screen: screen, - isPreview: true - ) - .flipY(maxY: NSScreen.screens[0].frame.maxY) - - // What is the screen's frame - Log.info("Target frame: \(targetWindowFrame.debugDescription)", category: .previewController) - - // Validate target frame before setting - guard targetWindowFrame.isFinite else { - Log.info("Invalid target frame calculated", category: .previewController) - return - } - - let isCurrentlyTransparent = windowController.window?.alphaValue == 0 - let shouldBecomeTransparent = targetWindowFrame.size.area == 0 - - // If the window is currently hidden, and the next action will present it. - if isCurrentlyTransparent, !shouldBecomeTransparent { - switch Defaults[.previewStartingPosition] { - case .screenCenter: - // No-op, this is the default behavior - break - case .radialMenu: - // Center the preview window on the initial mouse position - let mousePosition = LoopManager.shared.initialMousePosition - let centerFrame: NSRect = .init(origin: mousePosition, size: .zero) - windowController.window?.setFrame(centerFrame, display: true) - case .actionCenter: - // Center the preview window on the action's target frame (at 80% size) - let previewWidth = targetWindowFrame.width * 0.8 - let previewHeight = targetWindowFrame.height * 0.8 - - let centerFrame: NSRect = .init( - x: targetWindowFrame.center.x - (previewWidth / 2), - y: targetWindowFrame.center.y - (previewHeight / 2), - width: previewWidth, - height: previewHeight - ) - - windowController.window?.setFrame(centerFrame, display: true) - } - } - - if !isCurrentlyTransparent, shouldBecomeTransparent, let currentFrame = windowController.window?.frame { - // Center the preview window on the last target frame (at 80% size) - let scaledWidth = currentFrame.width * 0.8 - let scaledHeight = currentFrame.height * 0.8 - - targetWindowFrame = .init( - x: currentFrame.center.x - (scaledWidth / 2), - y: currentFrame.center.y - (scaledHeight / 2), - width: scaledWidth, - height: scaledHeight - ) - } - - let animationConfiguration = Defaults[.animationConfiguration] - if let timingFunction = animationConfiguration.previewTimingFunction { - NSAnimationContext.runAnimationGroup { context in - context.timingFunction = timingFunction - context.duration = animationConfiguration.previewTimingDuration - windowController.window?.animator().setFrame(targetWindowFrame, display: true) - windowController.window?.animator().alphaValue = shouldBecomeTransparent ? 0 : 1 - } - } else { - windowController.window?.setFrame(targetWindowFrame, display: true) - windowController.window?.alphaValue = shouldBecomeTransparent ? 0 : 1 - } - - Log.ui("Set action to '\(newAction.description)'", category: .previewController) } } diff --git a/Loop/Window Action Indicators/Preview Window/PreviewView.swift b/Loop/Window Action Indicators/Preview Window/PreviewView.swift index a27431b2..9773ed4e 100644 --- a/Loop/Window Action Indicators/Preview Window/PreviewView.swift +++ b/Loop/Window Action Indicators/Preview Window/PreviewView.swift @@ -34,6 +34,19 @@ struct PreviewView: View { } var body: some View { + windowView() + .compositingGroup() + .frame(width: viewModel.computedFrame.width, height: viewModel.computedFrame.height) + .offset(x: viewModel.computedFrame.minX, y: viewModel.computedFrame.minY) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + .opacity(viewModel.isShown ? 1 : 0) + } + + private func windowView() -> some View { ZStack { ZStack { VisualEffectView(material: .hudWindow, blendingMode: .behindWindow, state: .active) diff --git a/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift b/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift index 99baba79..a9e75bd3 100644 --- a/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift +++ b/Loop/Window Action Indicators/Preview Window/PreviewViewModel.swift @@ -6,25 +6,132 @@ // import Defaults +import Scribe import SwiftUI +@Loggable +@MainActor final class PreviewViewModel: ObservableObject { - @Published var overrideCornerRadii: RectangleCornerRadii? + @Published private(set) var computedFrame: CGRect = .zero + @Published private(set) var isShown: Bool = false + @Published private(set) var overrideCornerRadii: RectangleCornerRadii? - init(window: Window?) { - if #available(macOS 26.0, *), let window { - self.overrideCornerRadii = Self.getCornerRadius(for: window) - } else { - self.overrideCornerRadii = nil + private let isSettingsPreview: Bool + + init(isSettingsPreview: Bool) { + self.isSettingsPreview = isSettingsPreview + } + + func setIsShown(_ newState: Bool) { + withAnimation(Defaults[.animationConfiguration].previewWindow) { + isShown = newState } } - func setWindow(to newWindow: Window?) { - if #available(macOS 26.0, *), let newWindow { - overrideCornerRadii = Self.getCornerRadius(for: newWindow) + func updateContext(with context: ResizeContext, isScreenSwitch: Bool) { + if #available(macOS 26.0, *), let window = context.window { + overrideCornerRadii = Self.getCornerRadius(for: window) } else { overrideCornerRadii = nil } + + let isCurrentlyHidden = !isShown + var paddedFrame = context.getTargetFrame().padded + + if let bounds = context.screen?.displayBounds { + paddedFrame.origin.x -= bounds.minX + paddedFrame.origin.y -= bounds.minY + } + + // In settings preview, actions that manipulate existing window frames (larger/smaller, + // grow/shrink, move) cannot be previewed without a real window. + let shouldBecomeVisible = if isSettingsPreview, context.action.willManipulateExistingWindowFrame { + false + } else { + paddedFrame.size.area > 0 + } + + var newShownState: Bool = isShown + var newComputedFrame: CGRect = computedFrame + + // If the window is currently shown, but needs to be hidden + if !isCurrentlyHidden, !shouldBecomeVisible { + newShownState = false + } + + // If the window is currently hidden, but it needs to be shown. + else if isCurrentlyHidden, shouldBecomeVisible { + if !isScreenSwitch { + let startingFrame = computeStartingFrame( + for: Defaults[.previewStartingPosition], + targetFrame: paddedFrame, + context: context + ) + + // Set starting position without animation + computedFrame = startingFrame + } + + newShownState = true + newComputedFrame = paddedFrame + } + + // Window is already visible and should stay visible - update frame + else if !isCurrentlyHidden, shouldBecomeVisible { + newComputedFrame = paddedFrame + } + + if isScreenSwitch { + computedFrame = newComputedFrame + isShown = newShownState + } else { + withAnimation(Defaults[.animationConfiguration].previewWindow) { + computedFrame = newComputedFrame + isShown = newShownState + } + } + + log.ui("Current previewed frame: \(computedFrame) for \(context.action)") + } + + private func computeStartingFrame( + for position: PreviewStartingPosition, + targetFrame: CGRect, + context: ResizeContext + ) -> CGRect { + switch position { + case .screenCenter: + // Animate from zero at center of screen + guard var centerPosition = context.screen?.frame.center else { + return targetFrame + } + if let screenFrame = context.screen?.frame { + centerPosition.x -= screenFrame.minX + centerPosition.y -= screenFrame.minY + } + return CGRect(origin: centerPosition, size: .zero) + + case .radialMenu: + // Center the preview window on the initial mouse position + var mousePosition = context.initialMousePosition + if let screenFrame = context.screen?.frame { + mousePosition.x -= screenFrame.minX + mousePosition.y -= screenFrame.minY + } + return CGRect(origin: mousePosition, size: .zero) + + case .actionCenter: + // Center the preview window on the action's target frame (at 80% size) + let previewWidth = targetFrame.width * 0.8 + let previewHeight = targetFrame.height * 0.8 + + return CGRect( + x: targetFrame.midX - previewWidth / 2, + y: targetFrame.midY - previewHeight / 2, + width: previewWidth, + height: previewHeight + ) + } } @available(macOS 26.0, *) diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift index 7b7fbbae..156fec73 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift @@ -9,39 +9,33 @@ import Defaults import Scribe import SwiftUI -final class RadialMenuController { +@Loggable +@MainActor +final class RadialMenuController: WindowActionIndicator { + private var viewModel: RadialMenuViewModel = .init(isSettingsPreview: false) private var controller: NSWindowController? - private var viewModel: RadialMenuViewModel? - func open( - position: CGPoint, - window: Window?, - startingAction: WindowAction - ) { - if let windowController = controller { - windowController.window?.orderFrontRegardless() + func open(context: ResizeContext) { + defer { viewModel.updateContext(with: context) } + + if let window = controller?.window { + window.orderFrontRegardless() return } - let viewModel = RadialMenuViewModel( - startingAction: startingAction, - window: window, - previewMode: false - ) - self.viewModel = viewModel - - let mouseX: CGFloat = position.x - let mouseY: CGFloat = position.y + let mouseX: CGFloat = context.initialMousePosition.x + let mouseY: CGFloat = context.initialMousePosition.y let windowSize: CGFloat = 100 + 80 let panel = ActivePanel( contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, - defer: true, - screen: NSApp.keyWindow?.screen + defer: true ) + controller = .init(window: panel) + panel.ignoresMouseEvents = true panel.collectionBehavior = .canJoinAllSpaces panel.hasShadow = false panel.backgroundColor = .clear @@ -49,7 +43,7 @@ final class RadialMenuController { panel.contentView = NSHostingView(rootView: RadialMenuView(viewModel: viewModel)) // Position the panel - if Defaults[.lockRadialMenuToCenter], let screen = NSApp.keyWindow?.screen ?? NSScreen.main { + if Defaults[.lockRadialMenuToCenter], let screen = NSScreen.main { // Position at the center of the screen let screenFrame = screen.frame panel.setFrameOrigin( @@ -70,27 +64,20 @@ final class RadialMenuController { panel.orderFrontRegardless() - controller = .init(window: panel) + log.ui("Initialized controller") } func close() { guard let windowController = controller else { return } controller = nil - Task { @MainActor in - viewModel?.setIsShown(false, animationDuration: 0.15) + Task { + viewModel.setIsShown(false, animationDuration: 0.15) try? await Task.sleep(for: .seconds(0.15)) + windowController.window?.orderOut(nil) windowController.close() - } - } - - func setWindow(to newWindow: Window) { - viewModel?.setWindow(to: newWindow) - } - func setAction(to newAction: WindowAction, parent: WindowAction?) { - viewModel?.setAction(to: newAction, parent: parent) - - Log.ui("Set action to '\(newAction.description)'", category: .radialMenuController) + log.ui("Controller closed") + } } } diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift index 81418821..7f596229 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift @@ -26,7 +26,7 @@ struct RadialMenuView: View { } private var shouldAppearActive: Bool { - !viewModel.previewMode || (viewModel.previewMode && appearsActive) + !viewModel.isSettingsPreview || (viewModel.isSettingsPreview && appearsActive) } var body: some View { @@ -42,16 +42,16 @@ struct RadialMenuView: View { .animation(animationConfiguration.radialMenuSize, value: viewModel.currentAction) .animation(luminareAnimation, value: [accentColorController.color1, accentColorController.color2]) .onAppear { - viewModel.setIsShown(true, animationDuration: viewModel.previewMode ? 0.0 : 0.1) + viewModel.setIsShown(true, animationDuration: viewModel.isSettingsPreview ? 0.0 : 0.1) } } @available(macOS 26.0, *) private func postTahoeView() -> some View { - /// GlassEffectContainer w/ the materialize glass effect transition causes an exception: - /// "The window has been marked as needing another Update Constraints..." - /// This bug can be reproduced on macOS 26.0.0 and 26.0.1. We have yet to find the macOS version where it starts working correctly and reliably, - /// but for now, we have disabled the materialization Liquid Glass transition. + // GlassEffectContainer with the materialize glass effect transition causes an exception: + // "The window has been marked as needing another Update Constraints..." + // This bug can be reproduced on macOS 26.0.0 and 26.0.1. We have yet to find the macOS version where it starts working correctly and reliably, + // but for now, we have disabled the materialization Liquid Glass transition. ZStack { if viewModel.isShown { ZStack { @@ -160,7 +160,6 @@ struct RadialMenuView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private func radialMenuBorder() -> some View { ZStack { if radialMenuCornerRadius >= radialMenuSize / 2 - 2 { @@ -181,7 +180,6 @@ struct RadialMenuView: View { } } - @ViewBuilder private func radialMenuMask() -> some View { ZStack { if radialMenuCornerRadius >= radialMenuSize / 2 - 2 { @@ -194,12 +192,20 @@ struct RadialMenuView: View { } } - @ViewBuilder private func overlayImage() -> some View { - if let image = viewModel.radialMenuImage { - image - .foregroundStyle(accentColorController.color1) - .font(.system(size: 20, weight: .bold)) + ZStack { + if let image = viewModel.radialMenuImage { + if #available(macOS 26.0, *) { + image + .transition(.symbolEffect(.drawOn, options: .speed(2))) + .contentTransition(.symbolEffect(.replace, options: .speed(2))) + } else { + image + } + } } + .foregroundStyle(accentColorController.color1) + .font(.system(size: 20, weight: .bold)) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift index 5e86c20c..c4054674 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -10,9 +10,10 @@ import SwiftUI /// This class is in charge of managing the state of the radial menu, including the current action, angle, and colors. /// By keeping the state separate, we are able to use the same `RadialMenuView` both in the app's settings, as well as in actual usage. +@MainActor final class RadialMenuViewModel: ObservableObject { @Published private(set) var angle: Double - @Published private(set) var currentAction: WindowAction + @Published private(set) var currentAction: WindowAction = .init(.noSelection) /// If a cycling action is chosen, this will represent the enclosing cycle action @Published private(set) var parentAction: WindowAction? @@ -21,23 +22,13 @@ final class RadialMenuViewModel: ObservableObject { @Published private(set) var isShadowShown: Bool = false private var previousAction: WindowAction? + private var context: ResizeContext? private var window: Window? - let previewMode: Bool - - init( - startingAction: WindowAction, - window: Window?, - previewMode: Bool - ) { - self.currentAction = startingAction - self.previousAction = startingAction - self.window = window - self.previewMode = previewMode - - // Auto-set properties - self.angle = .zero + let isSettingsPreview: Bool - recomputeAngle() + init(isSettingsPreview: Bool) { + self.isSettingsPreview = isSettingsPreview + self.angle = .zero } private var effectiveWindowAction: WindowAction { @@ -81,13 +72,12 @@ final class RadialMenuViewModel: ObservableObject { } var radialMenuImage: Image? { - if window == nil, !previewMode { - return Image(systemName: "exclamationmark.triangle") + if window == nil, !isSettingsPreview { + Image(systemName: "exclamationmark.triangle") } else if let image = currentAction.image { - let image = image.withSymbolConfiguration(.init(pointSize: 20, weight: .bold)) ?? image - return Image(nsImage: image) + image.image } else { - return nil + nil } } @@ -111,20 +101,20 @@ final class RadialMenuViewModel: ObservableObject { } } - func setWindow(to newWindow: Window) { - window = newWindow - } + func updateContext(with context: ResizeContext) { + window = context.window - func setAction(to action: WindowAction, parent: WindowAction? = nil) { previousAction = currentAction - currentAction = action - parentAction = parent + currentAction = context.action + parentAction = context.parentAction - recomputeAngle() + recomputeAngle(context: context) } - func recomputeAngle() { - guard let targetAngle = calculateTargetAngle() else { return } + private func recomputeAngle(context: ResizeContext) { + guard let targetAngle = calculateTargetAngle(context: context) else { + return + } let closestAngle = Angle.degrees(angle).angleDifference(to: targetAngle) let shouldAnimate = shouldAnimateTransition(closestAngle: closestAngle) @@ -135,7 +125,7 @@ final class RadialMenuViewModel: ObservableObject { } } - private func calculateTargetAngle() -> Angle? { + private func calculateTargetAngle(context: ResizeContext) -> Angle? { // Check directional radial menu actions first if let index = directionalRadialMenuActions.firstIndex(where: { $0.associatedActionId == effectiveWindowAction.id }) { let actionAngleSpan = 360.0 / CGFloat(directionalRadialMenuActions.count) @@ -143,7 +133,7 @@ final class RadialMenuViewModel: ObservableObject { } // Otherwise, default to the current action's radial menu angle - return currentAction.radialMenuAngle(window: window) + return currentAction.radialMenuAngle(context: context) } private func shouldAnimateTransition(closestAngle: Angle) -> Bool { diff --git a/Loop/Window Action Indicators/WindowActionIndicator.swift b/Loop/Window Action Indicators/WindowActionIndicator.swift new file mode 100644 index 00000000..6ee93811 --- /dev/null +++ b/Loop/Window Action Indicators/WindowActionIndicator.swift @@ -0,0 +1,13 @@ +// +// WindowActionIndicator.swift +// Loop +// +// Created by Kai Azim on 2026-01-19. +// + +import Foundation + +protocol WindowActionIndicator { + func open(context: ResizeContext) + func close() +} diff --git a/Loop/Window Action Indicators/WindowActionIndicatorService.swift b/Loop/Window Action Indicators/WindowActionIndicatorService.swift new file mode 100644 index 00000000..7ceb204a --- /dev/null +++ b/Loop/Window Action Indicators/WindowActionIndicatorService.swift @@ -0,0 +1,35 @@ +// +// WindowActionIndicatorService.swift +// Loop +// +// Created by Kai Azim on 2026-01-19. +// + +import AppKit +import Defaults + +@MainActor +final class WindowActionIndicatorService { + private let radialMenuController = RadialMenuController() + private let previewController = PreviewController() + + func openAndUpdate(context: ResizeContext) { + if Defaults[.hideOnNoSelection], context.action.direction == .noSelection { + closeAll() + return + } + + if Defaults[.previewVisibility] { + previewController.open(context: context) + } + + if Defaults[.radialMenuVisibility] { + radialMenuController.open(context: context) + } + } + + func closeAll() { + radialMenuController.close() + previewController.close() + } +} diff --git a/Loop/Window Management/Window Action/IconView.swift b/Loop/Window Management/Window Action/IconView.swift new file mode 100644 index 00000000..f8e7267d --- /dev/null +++ b/Loop/Window Management/Window Action/IconView.swift @@ -0,0 +1,276 @@ +// +// IconView.swift +// Loop +// +// Created by Kai Azim on 2026-01-21. +// + +import SwiftUI + +/// An icon to represent a `WindowAction`. +/// When the action is a cycle, it will display the first action in the cycle. +/// Icons will prioritize using the action's `icon` property, then a simple frame preview, and finally a default icon. +/// - the `icon` property is used for common actions like hide, minimize, growing and shrinking, which cannot be easily represented by a frame. +/// - a simple frame preview is used for more general actions such as right half, maximize, and center, as well as custom keybinds when available. +/// - finally, a default icon is used for cycle actions and actions without a specific icon or frame representation as backup (just in case, they shouldn't be needed in practice). +struct IconView: NSViewRepresentable { + private let action: WindowAction + private let size: CGSize + + init( + action: WindowAction, + size: CGSize = .init( + width: 18, + height: 14 + ) + ) { + self.action = action + self.size = size + } + + func makeNSView(context _: Context) -> IconRenderView { + let view = IconRenderView() + + if action.direction == .cycle, let first = action.cycle?.first { + view.setAction(to: first, animated: false) + } else { + view.setAction(to: action, animated: false) + } + + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: size.width), + view.heightAnchor.constraint(equalToConstant: size.height) + ]) + + return view + } + + func updateNSView(_ view: IconRenderView, context _: Context) { + if action.direction == .cycle, let first = action.cycle?.first { + view.setAction(to: first, animated: true) + } else { + view.setAction(to: action, animated: true) + } + } +} + +final class IconRenderView: NSView { + private var currentAction: WindowAction = .init(.noAction) + private var lastDisplayMode: DisplayMode? + + private let strokeLayer = CAShapeLayer() + private let fillLayer = CAShapeLayer() + private let imageLayer = CALayer() + + private let cornerRadius: CGFloat = 3 + private let inset: CGFloat = 2 + private let strokeWidth: CGFloat = 1.5 + + enum DisplayMode { + case frame(CGRect) + case image(NSImage) + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + func setAction( + to action: WindowAction, + animated: Bool + ) { + guard action != currentAction else { return } + currentAction = action + updatePath(duration: animated ? 0.2 : 0.0) + } + + override func layout() { + super.layout() + updatePath(duration: 0.0) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateColors() + } + + // MARK: - Private + + private func setup() { + wantsLayer = true + clipsToBounds = true + + layer?.addSublayer(strokeLayer) + layer?.addSublayer(fillLayer) + layer?.addSublayer(imageLayer) + + strokeLayer.lineWidth = 1 + strokeLayer.cornerCurve = .continuous + fillLayer.cornerCurve = .continuous + imageLayer.contentsGravity = .resizeAspect + + updateColors() + } + + private func updateColors() { + strokeLayer.fillColor = .clear + strokeLayer.strokeColor = NSColor.textColor.cgColor + fillLayer.fillColor = NSColor.textColor.cgColor + + if case let .image(image) = lastDisplayMode { + imageLayer.contents = processImage(image, color: .textColor) + } + } + + private func updatePath(duration: CFTimeInterval) { + strokeLayer.frame = bounds + fillLayer.frame = bounds + + let strokeInset = strokeWidth / 2 + processStrokeLayerPath(strokeInset: strokeInset) + + let fillInset = strokeInset + inset + let fillBounds = bounds.insetBy(dx: fillInset, dy: fillInset) + + guard let displayMode = determineDisplayMode(fillBounds: fillBounds) else { + fillLayer.opacity = 0 + imageLayer.opacity = 0 + return + } + + switch displayMode { + case let .frame(fillRect): + let newPath = CGPath( + roundedRect: fillRect, + cornerWidth: cornerRadius - inset, + cornerHeight: cornerRadius - inset, + transform: nil + ) + animateAlpha(layer: fillLayer, to: 1, duration: duration) + animateAlpha(layer: imageLayer, to: 0, duration: duration) + animatePath(layer: fillLayer, to: newPath, duration: duration) + case let .image(image): + imageLayer.contents = processImage(image, color: .textColor) + imageLayer.frame = getImageBounds() + animateAlpha(layer: fillLayer, to: 0, duration: duration) + animateAlpha(layer: imageLayer, to: 1, duration: duration) + } + + lastDisplayMode = displayMode + } + + private func processStrokeLayerPath(strokeInset: CGFloat) { + let strokeRect = bounds.insetBy(dx: strokeInset, dy: strokeInset) + let strokePath = CGPath( + roundedRect: strokeRect, + cornerWidth: cornerRadius, + cornerHeight: cornerRadius, + transform: nil + ) + strokeLayer.path = strokePath + } + + private func determineDisplayMode(fillBounds: CGRect) -> DisplayMode? { + if let image = currentAction.image { + return .image(image.nsImage) + } + + let frame = WindowFrameResolver.getFrame( + for: currentAction, + window: nil, + bounds: .init(origin: .zero, size: .init(width: 1, height: 1)) + ).flipY(maxY: 1) + + if frame.size.area != 0 { + let fillFrame = CGRect( + x: fillBounds.minX + fillBounds.width * frame.minX, + y: fillBounds.minY + fillBounds.height * frame.minY, + width: fillBounds.width * frame.width, + height: fillBounds.height * frame.height + ) + + return .frame(fillFrame) + } + + // And if all else fails... + + if currentAction.direction == .custom, let image = NSImage(systemSymbolName: "slider.horizontal.3", accessibilityDescription: nil) { + return .image(image) + } + + if currentAction.direction == .cycle, let image = NSImage(systemSymbolName: "repeat", accessibilityDescription: nil) { + return .image(image) + } + + return nil + } + + private func animatePath( + layer: CAShapeLayer, + to target: CGPath, + duration: CFTimeInterval + ) { + if duration > 0 { + let animation = CABasicAnimation(keyPath: "path") + animation.fromValue = layer.path + animation.toValue = target + animation.duration = duration + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + layer.add(animation, forKey: "path") + } + + layer.path = target + } + + private func animateAlpha( + layer: CALayer, + to target: Float, + duration: CFTimeInterval + ) { + if duration > 0 { + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = layer.opacity + animation.toValue = target + animation.duration = duration + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + layer.add(animation, forKey: "opacity") + } + + layer.opacity = target + } + + private func processImage(_ image: NSImage, color: NSColor) -> NSImage? { + guard image.isTemplate else { return image } + let image = image.withSymbolConfiguration(.init(pointSize: 12, weight: .bold)) ?? image + + let sizedImage = NSImage(size: image.size) + sizedImage.lockFocus() + defer { sizedImage.unlockFocus() } + + image.draw(at: .zero, from: .zero, operation: .sourceOver, fraction: 1) + color.setFill() + let rect = NSRect(origin: .zero, size: image.size) + rect.fill(using: .sourceIn) + + return sizedImage + } + + private func getImageBounds() -> NSRect { + let insetBounds = bounds.insetBy(dx: strokeWidth, dy: strokeWidth) + let side = min(insetBounds.width, insetBounds.height) + let squareRect = CGRect( + x: insetBounds.midX - side / 2, + y: insetBounds.midY - side / 2, + width: side, + height: side + ) + return squareRect + } +} diff --git a/Loop/Window Management/Window Action/WindowAction+Image.swift b/Loop/Window Management/Window Action/WindowAction+Image.swift index 55500dea..590a71b0 100644 --- a/Loop/Window Management/Window Action/WindowAction+Image.swift +++ b/Loop/Window Management/Window Action/WindowAction+Image.swift @@ -8,337 +8,93 @@ import Luminare import SwiftUI +enum WindowActionImage { + case systemImage(String) + case resource(ImageResource) + + var image: Image { + switch self { + case let .systemImage(string): + Image(systemName: string) + case let .resource(resource): + Image(resource) + } + } + + var nsImage: NSImage { + switch self { + case let .systemImage(string): + let image = NSImage(systemSymbolName: string, accessibilityDescription: nil) + return image?.withSymbolConfiguration(.init(pointSize: 20, weight: .bold)) ?? image ?? NSImage() + case let .resource(resource): + return NSImage(resource: resource) + } + } +} + extension WindowAction { - var image: NSImage? { + var image: WindowActionImage? { switch direction { case .noAction: - NSImage(systemSymbolName: "questionmark", accessibilityDescription: nil) + .systemImage("questionmark") case .undo: - NSImage(systemSymbolName: "arrow.uturn.backward", accessibilityDescription: nil) + .systemImage("arrow.uturn.backward") case .initialFrame: - NSImage(systemSymbolName: "backward.end.alt.fill", accessibilityDescription: nil) + .systemImage("backward.end.fill") case .hide: - NSImage(systemSymbolName: "eye.slash.fill", accessibilityDescription: nil) + .systemImage("eye.slash") case .minimize: - NSImage(systemSymbolName: "arrow.down.right.and.arrow.up.left", accessibilityDescription: nil) + .systemImage("arrow.down.right.and.arrow.up.left") case .minimizeOthers: - NSImage(systemSymbolName: "arrow.down.right.and.arrow.up.left", accessibilityDescription: nil) + .systemImage("arrow.down.right.and.arrow.up.left") case .maximizeHeight: - NSImage(systemSymbolName: "arrow.up.and.down", accessibilityDescription: nil) + .systemImage("arrow.up.and.down") case .maximizeWidth: - NSImage(systemSymbolName: "arrow.left.and.right", accessibilityDescription: nil) + .systemImage("arrow.left.and.right") case .nextScreen: - NSImage(systemSymbolName: "forward.fill", accessibilityDescription: nil) + .systemImage("arrow.forward") case .previousScreen: - NSImage(systemSymbolName: "backward.fill", accessibilityDescription: nil) + .systemImage("arrow.backward") case .leftScreen: - NSImage(systemSymbolName: "arrow.left.to.line", accessibilityDescription: nil) + .systemImage("arrow.left.to.line") case .rightScreen: - NSImage(systemSymbolName: "arrow.right.to.line", accessibilityDescription: nil) + .systemImage("arrow.right.to.line") case .topScreen: - NSImage(systemSymbolName: "arrow.up.to.line", accessibilityDescription: nil) + .systemImage("arrow.up.to.line") case .bottomScreen: - NSImage(systemSymbolName: "arrow.down.to.line", accessibilityDescription: nil) + .systemImage("arrow.down.to.line") case .fillAvailableSpace, .larger, .scaleUp: - NSImage(systemSymbolName: "arrow.up.left.and.arrow.down.right", accessibilityDescription: nil) + .systemImage("arrow.up.left.and.arrow.down.right") case .smaller, .scaleDown: - NSImage(systemSymbolName: "arrow.down.right.and.arrow.up.left", accessibilityDescription: nil) + .systemImage("arrow.down.right.and.arrow.up.left") case .shrinkTop, .growBottom, .moveDown: - NSImage(systemSymbolName: "arrow.down", accessibilityDescription: nil) + .systemImage("arrow.down") case .shrinkBottom, .growTop, .moveUp: - NSImage(systemSymbolName: "arrow.up", accessibilityDescription: nil) + .systemImage("arrow.up") case .shrinkRight, .growLeft, .moveLeft: - NSImage(systemSymbolName: "arrow.left", accessibilityDescription: nil) + .systemImage("arrow.left") case .shrinkLeft, .growRight, .moveRight: - NSImage(systemSymbolName: "arrow.right", accessibilityDescription: nil) + .systemImage("arrow.right") case .shrinkHorizontal: - NSImage(systemSymbolName: "arrow.right.and.line.vertical.and.arrow.left", accessibilityDescription: nil) + .systemImage("arrow.right.and.line.vertical.and.arrow.left") case .growHorizontal: - NSImage(systemSymbolName: "arrow.left.and.line.vertical.and.arrow.right", accessibilityDescription: nil) + .systemImage("arrow.left.and.line.vertical.and.arrow.right") case .shrinkVertical: - NSImage(systemSymbolName: "arrow.down.and.line.horizontal.and.arrow.up", accessibilityDescription: nil) + .systemImage("arrow.down.and.line.horizontal.and.arrow.up") case .growVertical: - NSImage(systemSymbolName: "arrow.up.and.line.horizontal.and.arrow.down", accessibilityDescription: nil) + .systemImage("arrow.up.and.line.horizontal.and.arrow.down") case .focusLeft: - NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil) + .systemImage("chevron.left") case .focusRight: - NSImage(systemSymbolName: "chevron.right", accessibilityDescription: nil) + .systemImage("chevron.right") case .focusUp: - NSImage(systemSymbolName: "chevron.up", accessibilityDescription: nil) + .systemImage("chevron.up") case .focusDown: - NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil) + .systemImage("chevron.down") case .focusNextInStack: - NSImage(systemSymbolName: "rectangle.stack", accessibilityDescription: nil) + .systemImage("rectangle.stack") default: nil } } } - -/// An icon to represent a `WindowAction`. -/// When the action is a cycle, it will display the first action in the cycle. -/// Icons will prioritize using the action's `icon` property, then a simple frame preview, and finally a default icon. -/// - the `icon` property is used for common actions like hide, minimize, growing and shrinking, which cannot be easily represented by a frame. -/// - a simple frame preview is used for more general actions such as right half, maximize, and center, as well as custom keybinds when available. -/// - finally, a default icon is used for cycle actions and actions without a specific icon or frame representation as backup (just in case, they shouldn't be needed in practice). -struct IconView: NSViewRepresentable { - private let action: WindowAction - private let size: CGSize - - init( - action: WindowAction, - size: CGSize = .init( - width: 18, - height: 14 - ) - ) { - self.action = action - self.size = size - } - - func makeNSView(context _: Context) -> IconRenderView { - let view = IconRenderView() - - if action.direction == .cycle, let first = action.cycle?.first { - view.setAction(to: first, animated: false) - } else { - view.setAction(to: action, animated: false) - } - - view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - view.widthAnchor.constraint(equalToConstant: size.width), - view.heightAnchor.constraint(equalToConstant: size.height) - ]) - - return view - } - - func updateNSView(_ view: IconRenderView, context _: Context) { - if action.direction == .cycle, let first = action.cycle?.first { - view.setAction(to: first, animated: true) - } else { - view.setAction(to: action, animated: true) - } - } -} - -final class IconRenderView: NSView { - private var currentAction: WindowAction = .init(.noAction) - private var lastDisplayMode: DisplayMode? - - private let strokeLayer = CAShapeLayer() - private let fillLayer = CAShapeLayer() - private let imageLayer = CALayer() - - private let cornerRadius: CGFloat = 3 - private let inset: CGFloat = 2 - private let strokeWidth: CGFloat = 1.5 - - enum DisplayMode { - case frame(CGRect) - case image(NSImage) - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setup() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - func setAction( - to action: WindowAction, - animated: Bool - ) { - guard action != currentAction else { return } - currentAction = action - updatePath(duration: animated ? 0.2 : 0.0) - } - - override func layout() { - super.layout() - updatePath(duration: 0.0) - } - - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - updateColors() - } - - // MARK: - Private - - private func setup() { - wantsLayer = true - clipsToBounds = true - - layer?.addSublayer(strokeLayer) - layer?.addSublayer(fillLayer) - layer?.addSublayer(imageLayer) - - strokeLayer.lineWidth = 1 - strokeLayer.cornerCurve = .continuous - fillLayer.cornerCurve = .continuous - imageLayer.contentsGravity = .resizeAspect - - updateColors() - } - - private func updateColors() { - strokeLayer.fillColor = .clear - strokeLayer.strokeColor = NSColor.textColor.cgColor - fillLayer.fillColor = NSColor.textColor.cgColor - - if case let .image(image) = lastDisplayMode { - imageLayer.contents = processImage(image, color: .textColor) - } - } - - private func updatePath(duration: CFTimeInterval) { - strokeLayer.frame = bounds - fillLayer.frame = bounds - - let strokeInset = strokeWidth / 2 - processStrokeLayerPath(strokeInset: strokeInset) - - let fillInset = strokeInset + inset - let fillBounds = bounds.insetBy(dx: fillInset, dy: fillInset) - - guard let displayMode = determineDisplayMode(fillBounds: fillBounds) else { - fillLayer.opacity = 0 - imageLayer.opacity = 0 - return - } - - switch displayMode { - case let .frame(fillRect): - let newPath = CGPath( - roundedRect: fillRect, - cornerWidth: cornerRadius - inset, - cornerHeight: cornerRadius - inset, - transform: nil - ) - animateAlpha(layer: fillLayer, to: 1, duration: duration) - animateAlpha(layer: imageLayer, to: 0, duration: duration) - animatePath(layer: fillLayer, to: newPath, duration: duration) - case let .image(image): - imageLayer.contents = processImage(image, color: .textColor) - imageLayer.frame = getImageBounds() - animateAlpha(layer: fillLayer, to: 0, duration: duration) - animateAlpha(layer: imageLayer, to: 1, duration: duration) - } - - lastDisplayMode = displayMode - } - - private func processStrokeLayerPath(strokeInset: CGFloat) { - let strokeRect = bounds.insetBy(dx: strokeInset, dy: strokeInset) - let strokePath = CGPath( - roundedRect: strokeRect, - cornerWidth: cornerRadius, - cornerHeight: cornerRadius, - transform: nil - ) - strokeLayer.path = strokePath - } - - private func determineDisplayMode(fillBounds: CGRect) -> DisplayMode? { - if let image = currentAction.image { - return .image(image) - } - - let frame = currentAction.getFrame( - window: nil, - bounds: .init(origin: .zero, size: .init(width: 1, height: 1)), - disablePadding: true - ).flipY(maxY: 1) - - if frame.size.area != 0 { - let fillFrame = CGRect( - x: fillBounds.minX + fillBounds.width * frame.minX, - y: fillBounds.minY + fillBounds.height * frame.minY, - width: fillBounds.width * frame.width, - height: fillBounds.height * frame.height - ) - - return .frame(fillFrame) - } - - // And if all else fails... - - if currentAction.direction == .custom, let image = NSImage(systemSymbolName: "slider.horizontal.3", accessibilityDescription: nil) { - return .image(image) - } - - if currentAction.direction == .cycle, let image = NSImage(systemSymbolName: "repeat", accessibilityDescription: nil) { - return .image(image) - } - - return nil - } - - private func animatePath( - layer: CAShapeLayer, - to target: CGPath, - duration: CFTimeInterval - ) { - if duration > 0 { - let animation = CABasicAnimation(keyPath: "path") - animation.fromValue = layer.path - animation.toValue = target - animation.duration = duration - animation.timingFunction = CAMediaTimingFunction(name: .easeOut) - layer.add(animation, forKey: "path") - } - - layer.path = target - } - - private func animateAlpha( - layer: CALayer, - to target: Float, - duration: CFTimeInterval - ) { - if duration > 0 { - let animation = CABasicAnimation(keyPath: "opacity") - animation.fromValue = layer.opacity - animation.toValue = target - animation.duration = duration - animation.timingFunction = CAMediaTimingFunction(name: .easeOut) - layer.add(animation, forKey: "opacity") - } - - layer.opacity = target - } - - private func processImage(_ image: NSImage, color: NSColor) -> NSImage? { - guard image.isTemplate else { return image } - let image = image.withSymbolConfiguration(.init(pointSize: 12, weight: .bold)) ?? image - - let sizedImage = NSImage(size: image.size) - sizedImage.lockFocus() - defer { sizedImage.unlockFocus() } - - image.draw(at: .zero, from: .zero, operation: .sourceOver, fraction: 1) - color.setFill() - let rect = NSRect(origin: .zero, size: image.size) - rect.fill(using: .sourceIn) - - return sizedImage - } - - private func getImageBounds() -> NSRect { - let insetBounds = bounds.insetBy(dx: strokeWidth, dy: strokeWidth) - let side = min(insetBounds.width, insetBounds.height) - let squareRect = CGRect( - x: insetBounds.midX - side / 2, - y: insetBounds.midY - side / 2, - width: side, - height: side - ) - return squareRect - } -} diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index 8d019a9b..8df21d0f 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -109,7 +109,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial var xPoint: Double? var yPoint: Double? - // Custom Cycle Properties + /// Custom Cycle Properties var cycle: [WindowAction]? // MARK: - Methods @@ -165,7 +165,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial } /// Determines if padding can be applied to the action. - var isPaddingApplicable: Bool { + var isInnerPaddingApplicable: Bool { if direction == .undo || direction == .initialFrame { return false } @@ -188,9 +188,9 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial /// - Minimizing the window (minimize) /// - Cycling through actions (cycle) - the selected action's angle will be used instead within the radial menu's selected action logic. /// - /// - Parameter window: the window to be manipulated. If `nil`, the angle will be calculated based on the screen center. + /// - Parameter context: the resize context containing the pre-computed target frame. /// - Returns: the angle to show in the radial menu, or `nil` if the action does not have a radial menu angle. - func radialMenuAngle(window: Window?) -> Angle? { + func radialMenuAngle(context: ResizeContext) -> Angle? { guard direction.frameMultiplyValues != nil, direction.hasRadialMenuAngle @@ -198,693 +198,12 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial return nil } - let frame = CGRect(origin: .zero, size: .init(width: 1, height: 1)) - let targetWindowFrame = getFrame(window: window, bounds: frame, disablePadding: true) - let angle = frame.center.angle(to: targetWindowFrame.center) + let targetFrame = context.getTargetFrame().normalized + let angle = CGPoint(x: 0.5, y: 0.5).angle(to: targetFrame.center) let result: Angle = angle * -1 return result.normalized() } - - /// Returns the frame for the specified window action within a given boundary. - /// - Parameters: - /// - window: the window to be manipulated. - /// - bounds: the boundary within which the window should be manipulated. - /// - disablePadding: whether to disable padding. `true` when calculating non-AX-usage frames, such as for angle calculations in radial menu or in config UI. - /// - screen: the screen on which the bounds are located. Only used to determine if padding should be applied (see `getBounds()`). - /// - isPreview: ensures that when manipulating the preview window, the last target frame does not affect the actual resizing of the window. - /// - Returns: the calculated frame for the specified window action. - func getFrame( - window: Window?, - bounds: CGRect, - disablePadding: Bool = false, - screen: NSScreen? = nil, - isPreview: Bool = false - ) -> CGRect { - let noFrameActions: [WindowDirection] = [.noAction, .noSelection, .cycle, .minimize, .hide] - guard !noFrameActions.contains(direction), !direction.willFocusWindow else { - return NSRect(origin: bounds.center, size: .zero) - } - - if !willManipulateExistingWindowFrame { - LoopManager.sidesToAdjust = nil - } - - let padding = disablePadding ? .zero : PaddingSettings.configuredPadding(for: screen) - var bounds = padding.apply(onScreenFrame: bounds) - var result: CGRect = calculateTargetFrame( - direction: direction, - window: window, - bounds: bounds, - padding: padding, - isPreview: isPreview - ) - - if !disablePadding { - if !willManipulateExistingWindowFrame { - /// Convert rects to integers as that's what the AX API works with to move windows - /// Only do this when `!willManipulateExistingWindowFrame`, as otherwise, the window will drift with consecutive calls. - bounds = bounds.integerRect() - result = result.integerRect() - } - - // 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) - } else { - // Apply padding between windows - if isPaddingApplicable { - result = applyInnerPadding( - windowFrame: result, - bounds: bounds, - screen: screen - ) - } - } - - // 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 - } - - if result.size.width < 0 || result.size.height < 0 || !result.isFinite { - result = CGRect(origin: bounds.center, size: .zero) - } - - return result - } -} - -// MARK: - Window Frame Calculations - -extension WindowAction { - /// Calculates the target frame for the specified window action based on the direction, window, bounds, and whether it is a preview. - /// - Parameters: - /// - direction: the direction of the window action. - /// - window: the window to be manipulated. - /// - bounds: the bounds within which the window should be manipulated. - /// - padding: the padding which will be applied to the computed frame. - /// - isPreview: whether the action is being performed on a preview window. - /// - Returns: the calculated target frame for the specified window action. - private func calculateTargetFrame( - direction: WindowDirection, - window: Window?, - bounds: CGRect, - padding: PaddingModel, - isPreview: Bool - ) -> CGRect { - var result: CGRect = .zero - - if direction.frameMultiplyValues != nil { - result = applyFrameMultiplyValues(to: bounds) - - } else if direction.willAdjustSize { - // Can't grow or shrink a window that is not resizable - if let window, !window.isResizable { - return window.frame - } - - // Return final frame of preview - if Defaults[.previewVisibility], !isPreview { - return LoopManager.lastTargetFrame - } - - let frameToResizeFrom = LoopManager.lastTargetFrame - - // calculateSizeAdjustment() will read LoopManager.sidesToAdjust, but we compute them here - let edgesTouchingBounds = frameToResizeFrom.getEdgesTouchingBounds(bounds) - LoopManager.sidesToAdjust = .all.subtracting(edgesTouchingBounds) - - let proportional: [WindowDirection] = [.scaleUp, .scaleDown] - result = calculateSizeAdjustment( - frameToResizeFrom: frameToResizeFrom, - bounds: bounds, - proportionalIfPossible: proportional.contains(direction), - padding: padding - ) - - } else if direction.willShrink || direction.willGrow { - // Can't grow or shrink a window that is not resizable - if let window, !window.isResizable { - return window.frame - } - - // Return final frame of preview - if Defaults[.previewVisibility], !isPreview { - return LoopManager.lastTargetFrame - } - - // This allows for control over each side - let frameToResizeFrom = LoopManager.lastTargetFrame - - // 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 - case .shrinkHorizontal, .growHorizontal: - LoopManager.sidesToAdjust = [.leading, .trailing] - case .shrinkVertical, .growVertical: - LoopManager.sidesToAdjust = [.top, .bottom] - default: - LoopManager.sidesToAdjust = .trailing - } - - result = calculateSizeAdjustment( - frameToResizeFrom: frameToResizeFrom, - bounds: bounds, - padding: padding - ) - - } else if direction.willMove { - // Return final frame of preview - if Defaults[.previewVisibility], !isPreview { - return LoopManager.lastTargetFrame - } - - let frameToResizeFrom = LoopManager.lastTargetFrame - - result = calculatePositionAdjustment(frameToResizeFrom: frameToResizeFrom) - - } else if direction.isCustomizable { - result = calculateCustomFrame(window: window, bounds: bounds) - - } else if direction == .center { - result = calculateCenterFrame(window: window, bounds: bounds) - - } else if direction == .macOSCenter { - result = calculateMacOSCenterFrame(window: window, bounds: bounds) - - } else if direction == .undo, let window { - result = getLastActionFrame(window: window, bounds: bounds) - - } else if direction == .initialFrame, let window { - result = getInitialFrame(window: window) - - } else if direction == .maximizeHeight, let window { - result = getMaximizeHeightFrame(window: window, bounds: bounds, padding: padding) - - } else if direction == .maximizeWidth, let window { - result = getMaximizeWidthFrame(window: window, bounds: bounds, padding: padding) - - } else if direction == .unstash, let window { - result = getInitialFrame(window: window) - - } else if direction == .fillAvailableSpace, let window { - result = getFillAvailableSpaceFrame(window: window) - } - - return result - } - - /// Applies the window direction's frame multiply values to the given bounds. - /// - Parameter bounds: the bounds to which the frame multiply values will be applied on. - /// - Returns: a new `CGRect` with the frame multiply values applied. - private func applyFrameMultiplyValues(to bounds: CGRect) -> CGRect { - guard let frameMultiplyValues = direction.frameMultiplyValues else { - return .zero - } - - 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 - ) - } - - /// Calculates the user-specified custom frame relative to the provided bounds. - /// - Parameters: - /// - window: the window to be manipulated. - /// - bounds: the bounds within which the window should be manipulated. - /// - Returns: the calculated custom frame based on the specified parameters. - private func calculateCustomFrame(window: Window?, bounds: CGRect) -> CGRect { - var result = CGRect(origin: bounds.origin, size: .zero) - - // Size Calculation - - if let sizeMode, sizeMode == .preserveSize, let window { - result.size = window.size - - } else if let sizeMode, sizeMode == .initialSize, let window { - if let initialFrame = WindowRecords.getInitialFrame(for: window) { - result.size = initialFrame.size - } - - } else { // sizeMode would be custom - switch unit { - case .pixels: - if window == nil { - let mainScreen = NSScreen.main ?? NSScreen.screens[0] - result.size.width = (CGFloat(width ?? .zero) / mainScreen.frame.width) * bounds.width - result.size.height = (CGFloat(height ?? .zero) / mainScreen.frame.height) * bounds.height - } else { - result.size.width = width ?? .zero - result.size.height = height ?? .zero - } - default: - if let width { - result.size.width = bounds.width * (width / 100.0) - } - - if let height { - result.size.height = bounds.height * (height / 100.0) - } - } - } - - // Position Calculation - - if let positionMode, positionMode == .coordinates { - switch unit { - case .pixels: - if window == nil { - let mainScreen = NSScreen.main ?? NSScreen.screens[0] - result.origin.x = (CGFloat(xPoint ?? .zero) / mainScreen.frame.width) * bounds.width - result.origin.y = (CGFloat(yPoint ?? .zero) / mainScreen.frame.height) * bounds.height - } else { - // Note that bounds are ignored deliberately here - result.origin.x += xPoint ?? .zero - result.origin.y += yPoint ?? .zero - } - default: - if let xPoint { - result.origin.x += bounds.width * (xPoint / 100.0) - } - - if let yPoint { - result.origin.y += bounds.height * (yPoint / 100.0) - } - } - } else { // positionMode would be generic - switch anchor { - case .top: - result.origin.x = bounds.midX - result.width / 2 - case .topRight: - result.origin.x = bounds.maxX - result.width - case .right: - result.origin.x = bounds.maxX - result.width - result.origin.y = bounds.midY - result.height / 2 - case .bottomRight: - result.origin.x = bounds.maxX - result.width - result.origin.y = bounds.maxY - result.height - case .bottom: - result.origin.x = bounds.midX - result.width / 2 - result.origin.y = bounds.maxY - result.height - case .bottomLeft: - result.origin.y = bounds.maxY - result.height - case .left: - result.origin.y = bounds.midY - result.height / 2 - case .center: - result.origin.x = bounds.midX - result.width / 2 - result.origin.y = bounds.midY - result.height / 2 - case .macOSCenter: - let yOffset = getMacOSCenterYOffset(windowHeight: result.height, screenHeight: bounds.height) - result.origin.x = bounds.midX - result.width / 2 - result.origin.y = (bounds.midY - result.height / 2) + yOffset - default: - break - } - } - - return result - } - - /// Calculates the center frame for the window based on the provided bounds. The window's size will not be manipulated if a valid window is passed in. - /// - Parameters: - /// - window: the window to be centered. If `nil`, the center frame will be calculated based on the bounds (and therefore resized) - /// - bounds: the bounds within which the window should be centered. - /// - Returns: the calculated center frame for the window. - private 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 - ) - } - - /// Calculates the "macOS center" frame for the window based on the provided bounds. The window's size will not be manipulated if a valid window is passed in. - /// - /// What is a "macOS center"? It is a center frame that is also shifted upwards by a certain amount, determined by the height of the window and the screen height. - /// Fun fact: this behavior can also be reproduced in your own NSWindows by calling its `center()` method! - /// - /// - Parameters: - /// - window: the window to be centered. If `nil`, the center frame will be calculated based on the bounds (and therefore resized) - /// - bounds: the bounds within which the window should be centered. - /// - Returns: the calculated "macOS center" frame for the window. - private 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 = getMacOSCenterYOffset( - windowHeight: windowSize.height, - screenHeight: bounds.height - ) - - return CGRect( - origin: CGPoint( - x: bounds.midX - (windowSize.width / 2), - y: bounds.midY - (windowSize.height / 2) + yOffset - ), - size: windowSize - ) - } - - /// 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. - private func getMacOSCenterYOffset(windowHeight: CGFloat, screenHeight: CGFloat) -> CGFloat { - let halfScreenHeight = screenHeight / 2 - let windowHeightPercent = windowHeight / screenHeight - return (0.5 * windowHeightPercent - 0.5) * halfScreenHeight - } - - /// Retrieves the last action frame for the specified window, based on the last action recorded in `WindowRecords`. - /// - Parameters: - /// - window: the window for which the last action frame is to be retrieved. - /// - bounds: the bounds within which the window should be manipulated. - /// - Returns: the frame of the last action performed on the window, or the current frame if no last action is found. - private func getLastActionFrame(window: Window, bounds: CGRect) -> CGRect { - if let previousAction = WindowRecords.getLastAction(for: window) { - Log.info("Last action was \(previousAction.description)", category: .windowAction) - - return previousAction.getFrame( - window: window, - bounds: bounds, - disablePadding: true - ) - } else { - Log.info("Didn't find frame to undo; using current frame", category: .windowAction) - return window.frame - } - } - - /// Retrieves the initial frame for the specified window, based on the initial frame recorded in `WindowRecords`. - /// - Parameter window: the window for which the initial frame is to be retrieved. - /// - Returns: the initial frame of the window, or the current frame if no initial frame is found. - private func getInitialFrame(window: Window) -> CGRect { - if let initialFrame = WindowRecords.getInitialFrame(for: window) { - return initialFrame - } else { - Log.info("Didn't find initial frame; using current frame", category: .windowAction) - return window.frame - } - } - - /// Computes a new window frame with the maximum height that fits within the given bounds. - /// The provided padding is factored in to account for later adjustments. - /// - Parameters: - /// - window: the window whose current frame is used as a reference. - /// - bounds: the area within which the window should be resized. - /// - padding: the padding to be applied to the window. - /// - Returns: a CGRect representing a frame that maximizes the window's height. - private func getMaximizeHeightFrame(window: Window, bounds: CGRect, padding: PaddingModel) -> CGRect { - CGRect( - x: window.frame.minX - padding.window / 2, - y: bounds.minY, - width: window.frame.width + padding.window, - height: bounds.height - ) - } - - /// Computes a new window frame with the maximum width that fits within the given bounds. - /// The provided padding is factored in to account for later adjustments. - /// - Parameters: - /// - window: the window whose current frame is used as a reference. - /// - bounds: the area within which the window should be resized. - /// - padding: the padding to be applied to the window. - /// - Returns: a CGRect representing a frame that maximizes the window's width. - private func getMaximizeWidthFrame(window: Window, bounds: CGRect, padding: PaddingModel) -> CGRect { - CGRect( - x: bounds.minX, - y: window.frame.minY - padding.window / 2, - width: bounds.width, - height: window.frame.height + padding.window - ) - } - - /// Computes a new window frame that takes up the most area, without overlapping with other windows. - /// Other windows that already overlap with the current window will be ignored. - /// - Parameter window: the window whose current frame is used as a reference. - /// - Returns: a CGRect representing a frame that makes a window fill the most available space. - private func getFillAvailableSpaceFrame(window: Window) -> CGRect { - let currentFrame = window.frame - - guard let screen = ScreenUtility.screenContaining(window) ?? NSScreen.main else { return currentFrame } - let screenFrame = screen.safeScreenFrame - - let nonIntersectingWindowFrames = WindowUtility.windowList() - .map(\.frame) - .filter { !$0.intersects(currentFrame) } // Ensure it doesn't intersect with the current window - .map { $0.intersection(screenFrame) } // Crop it to the screen frame - - /// Computes the closest window obstacle in each of the four cardinal directions - /// (left, right, top, bottom) relative to the current window, and returns the boundaries - /// formed by these obstacles, constrained to the screen frame. - func computeBoundaries() -> (minX: CGFloat, minY: CGFloat, maxX: CGFloat, maxY: CGFloat) { - var minX = screenFrame.minX - var minY = screenFrame.minY - var maxX = screenFrame.maxX - var maxY = screenFrame.maxY - - for frame in nonIntersectingWindowFrames { - if frame.maxX <= currentFrame.minX { minX = max(minX, frame.maxX) } - if frame.maxY <= currentFrame.minY { minY = max(minY, frame.maxY) } - if frame.minX >= currentFrame.maxX { maxX = min(maxX, frame.minX) } - if frame.minY >= currentFrame.maxY { maxY = min(maxY, frame.minY) } - } - - return (minX, minY, maxX, maxY) - } - - let (minX, minY, maxX, maxY) = computeBoundaries() - - // Needed for Hashable conformance - struct Boundary: Hashable { - let min: CGFloat - let max: CGFloat - } - - let uniqueXBoundaries: Set = [ - Boundary(min: minX, max: maxX), // Respect obstacles in both directions - Boundary(min: currentFrame.minX, max: maxX), // Keep left, expand right - Boundary(min: minX, max: currentFrame.maxX), // Expand left, keep right - Boundary(min: currentFrame.minX, max: screenFrame.maxX), // Keep left, expand right to screen edge - Boundary(min: screenFrame.minX, max: currentFrame.maxX), // Expand left to screen edge, keep right - Boundary(min: screenFrame.minX, max: screenFrame.maxX) // Full screen width - ] - - let uniqueYBoundaries: Set = [ - Boundary(min: minY, max: maxY), // Respect obstacles in both directions - Boundary(min: currentFrame.minY, max: maxY), // Keep bottom, expand top - Boundary(min: minY, max: currentFrame.maxY), // Expand bottom, keep top - Boundary(min: currentFrame.minY, max: screenFrame.maxY), // Keep bottom, expand top to screen edge - Boundary(min: screenFrame.minY, max: currentFrame.maxY), // Expand bottom to screen edge, keep top - Boundary(min: screenFrame.minY, max: screenFrame.maxY) // Full screen height - ] - - // Generate all possible combinations of x/y boundaries and filter it to valid candidates. - // A candidate is valid if it doesn't overlap with any other window. - let validCandidates = uniqueXBoundaries.flatMap { xBound in - uniqueYBoundaries.compactMap { yBound in - let combination = CGRect( - x: xBound.min, - y: yBound.min, - width: xBound.max - xBound.min, - height: yBound.max - yBound.min - ) - - return nonIntersectingWindowFrames.allSatisfy { !$0.intersects(combination) } ? combination : nil - } - } - - return validCandidates.max { $0.size.area < $1.size.area } ?? currentFrame - } - - /// Calculates the size adjustment for the specified frame based on the bounds and the direction of the action. - /// - Parameters: - /// - frameToResizeFrom: the frame to apply the size adjustment to. - /// - bounds: the bounds within which the frame should be resized. - /// - proportionalIfPossible: if true and all edges are resized, scales proportionally about the center instead of insetting each side. - /// - Returns: the adjusted frame after applying the size adjustment based on the direction and bounds. - private func calculateSizeAdjustment( - frameToResizeFrom: CGRect, - bounds: CGRect, - proportionalIfPossible: Bool = false, - padding: PaddingModel - ) -> CGRect { - let step = Defaults[.sizeIncrement] * ((direction == .larger || direction == .scaleUp || direction.willGrow) ? -1 : 1) - - let previewPadding = Defaults[.previewPadding] - let minSize = CGSize( - width: padding.left + padding.right + previewPadding + 100, - height: padding.totalTopPadding + padding.bottom + previewPadding + 100 - ) - - func insetAllEdges(_ rect: CGRect) -> CGRect { - rect.inset(by: step, minSize: minSize) - } - - func scaleAllEdgesIfPossible(_ rect: CGRect) -> CGRect? { - guard proportionalIfPossible, rect.width > 0, rect.height > 0 else { return nil } - - let sx = (rect.width - 2 * step) / rect.width - let sy = (rect.height - 2 * step) / rect.height - var targetUniformScale = min(sx, sy) - - guard targetUniformScale.isFinite, targetUniformScale > 0 else { return nil } - let minScaleToSatisfyMinWidth = minSize.width / rect.width - let minScaleToSatisfyMinHeight = minSize.height / rect.height - let minUniformScale = max(minScaleToSatisfyMinWidth, minScaleToSatisfyMinHeight) - targetUniformScale = max(targetUniformScale, minUniformScale) - - let rectCenter = CGPoint( - x: rect.midX, - y: rect.midY - ) - - let scaledSize = CGSize( - width: rect.width * targetUniformScale, - height: rect.height * targetUniformScale - ) - - let scaledRect = CGRect( - x: rectCenter.x - scaledSize.width / 2, - y: rectCenter.y - scaledSize.height / 2, - width: scaledSize.width, - height: scaledSize.height - ) - - return scaledRect - } - - var result = frameToResizeFrom - - if let edges = LoopManager.sidesToAdjust { - let resizeAllEdges = edges.isEmpty || edges.contains(.all) - - if resizeAllEdges { - result = scaleAllEdgesIfPossible(result) ?? insetAllEdges(result) - } else { - result = result.padding(edges, step) - - if result.width < minSize.width { - result.size.width = minSize.width - result.origin.x = frameToResizeFrom.midX - minSize.width / 2 - } - if result.height < minSize.height { - result.size.height = minSize.height - result.origin.y = frameToResizeFrom.midY - minSize.height / 2 - } - } - } - - result = result - .intersection(bounds) - - if result.size.approximatelyEqual(to: LoopManager.lastTargetFrame.size, tolerance: 2) { - result = LoopManager.lastTargetFrame - } - - return result - } - - /// Calculates the position adjustment for the specified frame based on the direction of the action. - /// - Parameter frameToResizeFrom: the frame to apply the position adjustment to. - /// - Returns: the adjusted frame after applying the position adjustment based on the direction. - private func calculatePositionAdjustment(frameToResizeFrom: CGRect) -> CGRect { - var result = frameToResizeFrom - - if direction == .moveUp { - result.origin.y -= Defaults[.sizeIncrement] - } else if direction == .moveDown { - result.origin.y += Defaults[.sizeIncrement] - } else if direction == .moveRight { - result.origin.x += Defaults[.sizeIncrement] - } else if direction == .moveLeft { - result.origin.x -= Defaults[.sizeIncrement] - } - - return result - } - - /// Applies inner padding to the specified window frame based on the direction and bounds. - /// "Inner padding" is the padding applied to the sides of the window frame, which aren't touching the side of the screen. - /// - Parameters: - /// - windowFrame: the frame of the window to which padding will be applied. - /// - bounds: the bounds within which the window should be padded. - /// - screen: the screen on which the bounds are located. This is used to determine if padding should be applied based on the screen size (if applicable). - /// - Returns: the window frame with the specified padding applied. - private func applyInnerPadding(windowFrame: CGRect, bounds: CGRect, screen: NSScreen?) -> CGRect { - guard !direction.willMove else { - return windowFrame - } - - var croppedWindowFrame = windowFrame.intersection(bounds) - - let paddingMinimumScreenSize = Defaults[.paddingMinimumScreenSize] - if paddingMinimumScreenSize != .zero, - screen?.diagonalSize ?? .zero < paddingMinimumScreenSize { - return windowFrame - } - - guard - !willManipulateExistingWindowFrame, - Defaults[.enablePadding] - else { - return croppedWindowFrame - } - - let padding = PaddingSettings.configuredPadding(for: screen) - let halfPadding = padding.window / 2 - - if direction == .macOSCenter, - windowFrame.height >= bounds.height { - croppedWindowFrame.origin.y = bounds.minY - croppedWindowFrame.size.height = bounds.height - } - - if direction == .center || direction == .macOSCenter { - return croppedWindowFrame - } - - if abs(croppedWindowFrame.minX - bounds.minX) > 1 { - croppedWindowFrame = croppedWindowFrame.padding(.leading, halfPadding) - } - - if abs(croppedWindowFrame.maxX - bounds.maxX) > 1 { - croppedWindowFrame = croppedWindowFrame.padding(.trailing, halfPadding) - } - - if abs(croppedWindowFrame.minY - bounds.minY) > 1 { - croppedWindowFrame = croppedWindowFrame.padding(.top, halfPadding) - } - - if abs(croppedWindowFrame.maxY - bounds.maxY) > 1 { - croppedWindowFrame = croppedWindowFrame.padding(.bottom, halfPadding) - } - - return croppedWindowFrame - } } extension WindowAction: CustomStringConvertible { diff --git a/Loop/Window Management/Window Action/WindowActionCache.swift b/Loop/Window Management/Window Action/WindowActionCache.swift index 62650101..c2fb8d1f 100644 --- a/Loop/Window Management/Window Action/WindowActionCache.swift +++ b/Loop/Window Management/Window Action/WindowActionCache.swift @@ -11,6 +11,7 @@ import Scribe /// Caches the user's actions in a dictionary keyed by its keybind. /// This is called from `KeybindObserver`, to retrieve the user's actions in an efficient manner. +@Loggable final class WindowActionCache { private(set) var actionsByKeybind: [Set: WindowAction] = [:] private(set) var bypassedActionsByKeybind: [Set: WindowAction] = [:] @@ -47,7 +48,7 @@ final class WindowActionCache { regenerateActionsByKeybind(from: keybinds) regenerateActionsByIdentifier(from: keybinds) - Log.info("Regenerated cache; normal: \(actionsByKeybind.count), bypassed: \(bypassedActionsByKeybind.count)", category: .windowActionCache) + log.info("Regenerated cache; normal: \(actionsByKeybind.count), bypassed: \(bypassedActionsByKeybind.count)") } private func regenerateActionsByKeybind(from keybinds: [WindowAction]) { diff --git a/Loop/Window Management/Window Action/WindowDirection.swift b/Loop/Window Management/Window Action/WindowDirection.swift index d296c8f1..ac2d750c 100644 --- a/Loop/Window Management/Window Action/WindowDirection.swift +++ b/Loop/Window Management/Window Action/WindowDirection.swift @@ -8,11 +8,11 @@ import Defaults import SwiftUI -// Enum that stores all possible resizing options +/// Enum that stores all possible resizing options enum WindowDirection: String, CaseIterable, Identifiable, Codable { var id: Self { self } - // "Empty" actions. + /// "Empty" actions. /// `noAction` is explicitly chosen or user-bound. /// `noSelection` is the default state before any radial menu selection is made. case noAction = "NoAction", noSelection = "NoSelection" @@ -45,30 +45,30 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { case verticalCenterThird = "VerticalCenterThird" case bottomThird = "BottomThird", bottomTwoThirds = "BottomTwoThirds" - // Screen Switching + /// Screen Switching case nextScreen = "NextScreen", previousScreen = "PreviousScreen", leftScreen = "LeftScreen", rightScreen = "RightScreen", topScreen = "TopScreen", bottomScreen = "BottomScreen" // Size Adjustment case larger = "Larger", smaller = "Smaller" case scaleUp = "ScaleUp", scaleDown = "ScaleDown" - // Shrink + /// Shrink case shrinkTop = "ShrinkTop", shrinkBottom = "ShrinkBottom", shrinkRight = "ShrinkRight", shrinkLeft = "ShrinkLeft", shrinkHorizontal = "ShrinkHorizontal", shrinkVertical = "ShrinkVertical" - // Grow + /// Grow case growTop = "GrowTop", growBottom = "GrowBottom", growRight = "GrowRight", growLeft = "GrowLeft", growHorizontal = "GrowHorizontal", growVertical = "GrowVertical" - // Move + /// Move case moveUp = "MoveUp", moveDown = "MoveDown", moveRight = "MoveRight", moveLeft = "MoveLeft" - // Focus + /// Focus case focusUp = "FocusUp", focusDown = "FocusDown", focusRight = "FocusRight", focusLeft = "FocusLeft", focusNextInStack = "FocusNextInStack" // Stash case stash = "Stash" case unstash = "Unstash" - // Custom Actions + /// Custom Actions case custom = "Custom", cycle = "Cycle" // These are used in the menubar resize submenu & keybind configuration @@ -87,6 +87,7 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { static var more: [WindowDirection] { [.initialFrame, .undo, .custom, .cycle] } // Computed properties for checking conditions + var isNoOp: Bool { [.noSelection, .noAction].contains(self) } var willChangeScreen: Bool { WindowDirection.screenSwitching.contains(self) } var willAdjustSize: Bool { WindowDirection.sizeAdjustment.contains(self) } var willShrink: Bool { WindowDirection.shrink.contains(self) } diff --git a/Loop/Window Management/Window Manipulation/PaddingConfiguration.swift b/Loop/Window Management/Window Manipulation/PaddingConfiguration.swift new file mode 100644 index 00000000..72b1f966 --- /dev/null +++ b/Loop/Window Management/Window Manipulation/PaddingConfiguration.swift @@ -0,0 +1,154 @@ +// +// PaddingConfiguration.swift +// Loop +// +// Created by Kai Azim on 2024-02-01. +// + +import Defaults +import SwiftUI + +struct PaddingConfiguration: Codable, Defaults.Serializable, Hashable { + var window: CGFloat + var externalBar: CGFloat + var top: CGFloat + var bottom: CGFloat + var right: CGFloat + var left: CGFloat + + var configureScreenPadding: Bool + + var totalTopPadding: CGFloat { + top + externalBar + } + + static var zero = PaddingConfiguration( + window: 0, + externalBar: 0, + top: 0, + bottom: 0, + right: 0, + left: 0, + configureScreenPadding: false + ) + + var allEqual: Bool { + window == top && window == bottom && window == right && window == left + } + + func applyToBounds( + _ bounds: CGRect + ) -> CGRect { + bounds + .padding(.leading, left) + .padding(.trailing, right) + .padding(.bottom, bottom) + .padding(.top, totalTopPadding) + } + + /// Applies padding to a frame that was calculated using non-padded bounds. + /// This scales the frame proportionally into the padded working area and applies inner window padding. + /// - Parameters: + /// - frame: The frame calculated using non-padded bounds. + /// - bounds: The original non-padded bounds (e.g., screen frame). + /// - action: The window action, used to determine padding behavior. + /// - window: The window being resized, if any. + /// - Returns: The frame with padding applied. + func applyToWindow( + frame: CGRect, + paddedBounds bounds: CGRect, + action: WindowAction, + window: Window? + ) -> CGRect { + guard bounds.width > 0, bounds.height > 0 else { return frame } + + var result = frame + + // Handle non-resizable windows by centering within the frame (no size changes) + if let window, window.isResizable == false { + let centeredFrame = window.frame.size + .center(inside: result) + + print(window.frame, centeredFrame) + + return centeredFrame + } + + // Apply inner padding if applicable + guard action.isInnerPaddingApplicable else { return result } + + result = applyInnerPadding( + to: result, + paddedBounds: bounds, + action: action + ) + + return result + } + + /// Applies inner window padding to the sides of the frame that don't touch the bounds edges. + private func applyInnerPadding(to frame: CGRect, paddedBounds: CGRect, action: WindowAction) -> CGRect { + guard !action.direction.willMove else { return frame } + + var result = frame.intersection(paddedBounds) + let halfPadding = window / 2 + + // Handle macOS center special case + if action.direction == .macOSCenter, frame.height >= paddedBounds.height { + result.origin.y = paddedBounds.minY + result.size.height = paddedBounds.height + } + + // Center actions don't get inner padding + if action.direction == .center || action.direction == .macOSCenter { + return result + } + + // Apply half padding to sides not touching bounds + if abs(result.minX - paddedBounds.minX) > 1 { + result = result.padding(.leading, halfPadding) + } + if abs(result.maxX - paddedBounds.maxX) > 1 { + result = result.padding(.trailing, halfPadding) + } + if abs(result.minY - paddedBounds.minY) > 1 { + result = result.padding(.top, halfPadding) + } + if abs(result.maxY - paddedBounds.maxY) > 1 { + result = result.padding(.bottom, halfPadding) + } + + return result + } +} + +extension PaddingConfiguration { + static func getConfiguredPadding(for screen: NSScreen?) -> PaddingConfiguration { + if #available(macOS 15, *), Defaults[.useSystemWindowManagerWhenAvailable] { + guard SystemWindowManager.MoveAndResize.enablePadding else { + return .zero + } + + let padding = SystemWindowManager.MoveAndResize.padding + + return PaddingConfiguration( + window: padding, + externalBar: 0, + top: padding, + bottom: padding, + right: padding, + left: padding, + configureScreenPadding: false + ) + } else { + let respectsPaddingThreshold = if let screen { + Defaults[.paddingMinimumScreenSize] == 0 || screen.diagonalSize > Defaults[.paddingMinimumScreenSize] + } else { + true + } + let enablePadding = Defaults[.enablePadding] && respectsPaddingThreshold + + return enablePadding ? Defaults[.padding] : .zero + } + } +} diff --git a/Loop/Window Management/Window Manipulation/ResizeContext.swift b/Loop/Window Management/Window Manipulation/ResizeContext.swift new file mode 100644 index 00000000..ef2ad01a --- /dev/null +++ b/Loop/Window Management/Window Manipulation/ResizeContext.swift @@ -0,0 +1,142 @@ +// +// ResizeContext.swift +// Loop +// +// Created by Kai Azim on 2026-01-19. +// + +import Scribe +import SwiftUI + +// MARK: - ResizeContext + +/// Holds transient state for a window resize operation. +/// This context tracks the target frame and which edges to adjust during grow/shrink actions, +/// along with the window, screen, and bounds information needed to compute frames. +@Loggable +final class ResizeContext { + private(set) var window: Window? + + private(set) var screen: NSScreen? + private(set) var bounds: CGRect + + private(set) var padding: PaddingConfiguration = .zero + private(set) var paddedBounds: CGRect + + private(set) var action: WindowAction = .init(.noSelection) + private(set) var parentAction: WindowAction? + + /// Used for larger/smaller actions where the sides to adjust need to persist across frame calculations + var sidesToAdjust: Edge.Set? + + /// Used to open radial menu at the correct position. + private(set) var initialMousePosition: CGPoint = .zero + + private(set) var cachedTargetFrame: ComputedFrame = .zero + private var needsRecompute: Bool = false + + init( + window: Window? = nil, + initialFrame: CGRect? = nil, + screen: NSScreen? = nil, + bounds: CGRect? = nil, + action: WindowAction = .init(.noSelection), + parentAction: WindowAction? = nil, + initialMousePosition: CGPoint = .zero + ) { + let frame = initialFrame ?? window?.frame ?? .zero + let bounds = bounds ?? screen?.cgSafeScreenFrame ?? .zero + let padding = PaddingConfiguration.getConfiguredPadding(for: screen) + + self.window = window + self.cachedTargetFrame = ComputedFrame(raw: frame, normalized: .zero, padded: frame) + self.screen = screen + self.bounds = bounds + self.padding = padding + self.paddedBounds = padding.applyToBounds(bounds) + self.action = action + self.parentAction = parentAction + self.initialMousePosition = initialMousePosition + self.needsRecompute = !action.direction.isNoOp + } + + func setScreen(to screen: NSScreen?) { + self.screen = screen + bounds = screen?.cgSafeScreenFrame ?? .zero + padding = PaddingConfiguration.getConfiguredPadding(for: screen) + paddedBounds = padding.applyToBounds(bounds) + needsRecompute = true + } + + func setWindow(to window: Window?) { + self.window = window + needsRecompute = true + + log.info("Set window to \(window?.description ?? "nil")") + } + + func setAction(to newAction: WindowAction, parent newParentAction: WindowAction?) { + action = newAction + parentAction = newParentAction + needsRecompute = true + } + + func getTargetFrame() -> ComputedFrame { + if needsRecompute { + recomputeTargetFrame() + } + + return cachedTargetFrame + } + + private func recomputeTargetFrame() { + let result = WindowFrameResolver.getFrame(resizeContext: self) + + let normalized = CGRect( + x: (result.frame.minX - bounds.minX) / bounds.width, + y: (result.frame.minY - bounds.minY) / bounds.height, + width: result.frame.width / bounds.width, + height: result.frame.height / bounds.height + ) + + let paddedFrame = padding.applyToWindow( + frame: result.frame, + paddedBounds: paddedBounds, + action: action, + window: window + ) + + cachedTargetFrame = ComputedFrame( + raw: result.frame, + normalized: normalized, + padded: paddedFrame + ) + needsRecompute = false + log.info("Computed target frame - raw: \(cachedTargetFrame.raw), normalized: \(cachedTargetFrame.normalized) padded: \(cachedTargetFrame.padded), for action: \(action)") + } +} + +// MARK: - ComputedFrame + +extension ResizeContext { + /// Holds both the raw (non-padded) and padded target frames for a resize operation. + struct ComputedFrame: Equatable { + /// The frame calculated without any padding applied. + let raw: CGRect + + /// The frame inside a 1x1 frame, used for radial menu angle calculations. + let normalized: CGRect + + /// The frame with padding applied (outer bounds padding + inner window padding). + /// When no padding is configured, this equals `raw`. + var padded: CGRect + + static let zero = ComputedFrame(raw: .zero, normalized: .zero, padded: .zero) + + init(raw: CGRect, normalized: CGRect, padded: CGRect) { + self.raw = raw + self.normalized = normalized + self.padded = padded + } + } +} diff --git a/Loop/Window Management/Window Manipulation/WindowActionEngine.swift b/Loop/Window Management/Window Manipulation/WindowActionEngine.swift new file mode 100644 index 00000000..cb52e6c4 --- /dev/null +++ b/Loop/Window Management/Window Manipulation/WindowActionEngine.swift @@ -0,0 +1,185 @@ +// +// WindowActionEngine.swift +// Loop +// +// Created by Kai Azim on 2023-06-16. +// + +import Defaults +import Scribe +import SwiftUI + +/// Unified entry point for executing all window actions. +/// +/// `WindowActionEngine` consolidates action execution logic previously scattered across +/// `WindowEngine`, `LoopManager`, and other files. It routes actions to appropriate handlers +/// and returns a result indicating success and any state changes. +/// +/// **Note:** Screen change actions (`nextScreen`, `previousScreen`, etc.) are NOT handled here. +/// They are resolved by `LoopManager` which updates `resizeContext.screen` before calling `apply()`. +@Loggable +final class WindowActionEngine { + static let shared = WindowActionEngine() + + private var actionTasks: [CGWindowID: Task] = [:] + + /// Result of applying a window action + struct Result { + /// Whether the action was successfully applied + let success: Bool + /// For focus actions that change the target window + let newTargetWindow: Window? + /// For resize actions via system WM that may update the frame + let updatedFrame: CGRect? + + static let noOp = Result(success: true, newTargetWindow: nil, updatedFrame: nil) + static let failed = Result(success: false, newTargetWindow: nil, updatedFrame: nil) + + static func focused(_ window: Window?) -> Result { + Result(success: window != nil, newTargetWindow: window, updatedFrame: nil) + } + + static func resized(frame: CGRect?) -> Result { + Result(success: true, newTargetWindow: nil, updatedFrame: frame) + } + } + + /// Simplified apply for callers that don't need resize context tracking (URL commands, drag snap, etc.) + /// + /// - Parameters: + /// - action: The action to apply + /// - window: The target window + /// - screen: The screen to perform the action on + /// - Returns: Result indicating success and any state changes + func apply( + _ action: WindowAction, + window: Window?, + screen: NSScreen + ) async throws -> Result { + let context = ResizeContext(window: window, screen: screen) + context.setAction(to: action, parent: nil) + return try await apply(context: context) + } + + /// Apply a window action with explicit resize context tracking. + /// The context should be updated by the caller before calling this function. + /// + /// - Parameters: + /// - action: The action to apply + /// - window: The target window (can be nil for some actions like focus navigation from screen center) + /// - resizeContext: Context containing tracking state for grow/shrink actions (passed by value, caller updates) + /// - Returns: Result indicating success and any state changes + /// - Throws: `CancellationError` if a new action is applied to the same window + @concurrent + func apply(context: ResizeContext) async throws -> Result { + guard let windowID = context.window?.cgWindowID else { + return try await performApply(context: context) + } + + // Cancel any existing action on this window + actionTasks[windowID]?.cancel() + + // Create a task for this action + let task = Task { + let result = try await performApply(context: context) + try Task.checkCancellation() + return result + } + actionTasks[windowID] = task + + // Await the task and clean up + let result = try await task.value + actionTasks.removeValue(forKey: windowID) + + return result + } + + private func performApply(context: ResizeContext) async throws -> Result { + log.info("Applying context: \(context)") + + let direction = context.action.direction + + // No-op actions: return early + if direction.isNoOp || direction == .cycle { + return .noOp + } + + // Focus actions: find and focus the target window + if direction.willFocusWindow { + return handleFocusAction(context.action, currentWindow: context.window) + } + + // Quick actions that don't require resize logic + if let result = handleQuickAction(context.action, window: context.window) { + return result + } + + // Perform the resize + let appliedFrame = try await WindowEngine.performResize(context: context) + + // Return the frame that should be stored (either from system WM or from calculation) + return .resized(frame: appliedFrame ?? context.getTargetFrame().padded) + } + + // MARK: - Focus Actions + + private func handleFocusAction(_ action: WindowAction, currentWindow: Window?) -> Result { + let direction = action.direction + var newTargetWindow: Window? + + if direction == .focusNextInStack { + newTargetWindow = WindowUtility.focusNextWindowInStack(from: currentWindow) + } else if let focusDirection = direction.focusDirection { + newTargetWindow = WindowUtility.focusWindow(from: currentWindow, direction: focusDirection) + } + + return .focused(newTargetWindow) + } + + // MARK: - Quick Actions + + /// Handles quick actions that don't require the full resize flow. + /// Returns nil if the action is not a quick action. + private func handleQuickAction(_ action: WindowAction, window: Window?) -> Result? { + guard let window else { + // Quick actions require a window + if [.hide, .minimize, .fullscreen, .minimizeOthers].contains(action.direction) { + log.info("Cannot apply quick action without a target window") + return .failed + } + return nil + } + + switch action.direction { + case .hide: + window.toggleHidden() + return .noOp + case .minimize: + window.toggleMinimized() + return .noOp + case .fullscreen: + window.toggleFullscreen() + return .noOp + case .minimizeOthers: + minimizeOtherWindows(exceptWindow: window) + return .noOp + default: + return nil + } + } + + // MARK: - Helpers + + private func minimizeOtherWindows(exceptWindow: Window) { + let allWindows = WindowUtility.windowList() + let windowsToMinimize = allWindows.filter { + $0.cgWindowID != exceptWindow.cgWindowID && !$0.minimized && !$0.isWindowHidden + } + + log.info("Minimizing \(windowsToMinimize.count) other windows") + + for window in windowsToMinimize { + window.minimized = true + } + } +} diff --git a/Loop/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index 6c564de5..7c6363c5 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -9,90 +9,48 @@ import Defaults import Scribe import SwiftUI -/// Handles execution of `WindowAction`s on windows within the user's workspace +/// Handles the low-level resize operations for windows. +/// Use `WindowActionEngine.apply()` as the main entry point for executing window actions. +@Loggable(style: .static) enum WindowEngine { - static var currentTask: Task<(), any Error>? - /// Resize a Window - /// - Parameters: - /// - window: Window to be resized - /// - action: WindowAction to resize the window to - /// - screen: Screen the window should be resized on - /// - completion: A completion handler. To be removed once we add proper Swift Concurrency support to LoopManager. - static func resize( - _ window: Window, - to action: WindowAction, - on screen: NSScreen, - completion: @escaping () -> () = {} - ) { - currentTask?.cancel() - currentTask = Task.detached(priority: .userInitiated) { - await resize( - window, - to: action, - on: screen - ) - - try Task.checkCancellation() - - completion() + /// Performs the actual resize operation on a window. + /// This is an internal method - callers should use `WindowActionEngine.apply()` instead. + static func performResize(context: ResizeContext) async throws -> CGRect? { + // Immediately return for no-op or focus-only actions + guard let window = context.window, + !context.action.direction.isNoOp, + !context.action.direction.willFocusWindow + else { + return nil } - } - /// Resize a Window asynchronously - /// - Parameters: - /// - window: Window to resize - /// - action: WindowAction describing the target layout - /// - screen: Screen the window should be resized on - private static func resize( - _ window: Window, - to action: WindowAction, - on screen: NSScreen - ) async { - // Immediately return for no-op or focus-only actions - guard action.direction != .noAction, - action.direction != .noSelection, - !action.direction.willFocusWindow - else { return } + // Quick actions are handled by WindowActionEngine + let quickActions: [WindowDirection] = [.hide, .minimize, .fullscreen, .minimizeOthers] + guard !quickActions.contains(context.action.direction) else { return nil } - let willChangeScreens = ScreenUtility.screenContaining(window) != screen - Log.info("Resizing \(window) to \(action.direction) on \(screen.localizedName)", category: .windowEngine) + let willChangeScreens = ScreenUtility.screenContaining(window) != context.screen + let targetFrame = context.getTargetFrame().padded + log.info("Resizing \(window) to \(targetFrame)") // Record first frame if needed WindowRecords.recordFirstIfNeeded(for: window) - let storeAsFrame = WindowRecords.shouldStoreAsFinalFrame(action) + let storeAsFrame = WindowRecords.shouldStoreAsFinalFrame(context.action) - /// If this action doesn't require storage as a frame, then record it beforehand. - /// Otherwise, this action will be recorded *after* resizing, such that its final frame is considered if undoing. + // If this action doesn't require storage as a frame, then record it beforehand. + // Otherwise, this action will be recorded *after* resizing, such that its final frame is considered if undoing. if !storeAsFrame { - WindowRecords.record(window, action) + WindowRecords.record(window, context.action) } defer { - if action.direction == .undo { + if context.action.direction == .undo { WindowRecords.removeLastAction(for: window) } else if storeAsFrame { - WindowRecords.record(window, action) + WindowRecords.record(window, context.action) } } - // Handle quick actions off the main actor - switch action.direction { - case .hide: - window.toggleHidden() - return - case .minimize: - window.toggleMinimized() - return - case .fullscreen: - window.toggleFullscreen() - return - case .minimizeOthers: - minimizeOtherWindows(exceptWindow: window) - return - default: break - } - let useSystemWM: Bool = if #available(macOS 15, *) { Defaults[.useSystemWindowManagerWhenAvailable] } else { @@ -103,25 +61,19 @@ enum WindowEngine { await window.focus() } + var systemWMFrame: CGRect? + // Attempt system window manager if possible if !willChangeScreens, useSystemWM, #available(macOS 15, *), - await resizeWithSystemWindowManager(window: window, to: action) { + await resizeWithSystemWindowManager(window: window, to: context.action) { if !Defaults[.previewVisibility] { - LoopManager.lastTargetFrame = window.frame + systemWMFrame = window.frame } } else { // Otherwise, we obviously need to disable fullscreen to resize the window window.fullscreen = false - let targetFrame = action.getFrame( - window: window, - bounds: screen.safeScreenFrame, - screen: screen - ) - - Log.info("Target window frame: \(targetFrame.debugDescription)", category: .windowEngine) - if window.nsRunningApplication?.bundleIdentifier == Bundle.main.bundleIdentifier { await resizeOwnWindow(targetFrame: targetFrame) } else { @@ -131,9 +83,8 @@ enum WindowEngine { try await resizeWindow( window, targetFrame: targetFrame, - screen: screen, + bounds: context.bounds, willChangeScreens: willChangeScreens, - ignorePadding: action.direction.willMove, animate: shouldAnimate ) } catch { @@ -146,11 +97,15 @@ enum WindowEngine { } } - StashManager.shared.onWindowResized( - action: action, - window: window, - screen: screen - ) + if let screen = context.screen { + StashManager.shared.onWindowResized( + action: context.action, + window: window, + screen: screen + ) + } + + return systemWMFrame } // MARK: - System Window Manager @@ -173,7 +128,7 @@ enum WindowEngine { let axMenuItem = try? systemAction.getItem(for: app), (try? axMenuItem.getValue(.enabled)) == true else { - Log.info("System action not available for \(action.direction.debugDescription) on \(window.title ?? "")", category: .windowEngine) + log.info("System action not available for \(action.direction.debugDescription) on \(window.title ?? "")") return false } @@ -200,7 +155,7 @@ enum WindowEngine { guard let window = NSApp.keyWindow ?? NSApp.windows.first(where: { $0.level.rawValue <= NSWindow.Level.floating.rawValue }) else { - Log.info("Failed to get own main window to resize", category: .windowEngine) + log.info("Failed to get own main window to resize") return } @@ -213,15 +168,10 @@ enum WindowEngine { private static func resizeWindow( _ window: Window, targetFrame: CGRect, - screen: NSScreen, + bounds: CGRect, willChangeScreens: Bool, - ignorePadding: Bool, animate: Bool ) async throws { - let bounds = ignorePadding ? .zero : - PaddingSettings.configuredPadding(for: screen) - .apply(onScreenFrame: screen.safeScreenFrame) - if animate { try await window.setFrameAnimated(targetFrame, bounds: bounds) } else { @@ -248,19 +198,4 @@ enum WindowEngine { window.position = windowFrame.origin } - - // MARK: - Minimize Others - - private static func minimizeOtherWindows(exceptWindow: Window) { - let allWindows = WindowUtility.windowList() - let windowsToMinimize = allWindows.filter { - $0.cgWindowID != exceptWindow.cgWindowID && !$0.minimized && !$0.isWindowHidden - } - - Log.info("Minimizing \(windowsToMinimize.count) other windows", category: .windowEngine) - - for window in windowsToMinimize { - window.minimized = true - } - } } diff --git a/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift b/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift new file mode 100644 index 00000000..ca12d06a --- /dev/null +++ b/Loop/Window Management/Window Manipulation/WindowFrameResolver.swift @@ -0,0 +1,612 @@ +// +// WindowFrameResolver.swift +// Loop +// +// Created by Kai Azim on 2026-01-20. +// + +import Defaults +import Scribe +import SwiftUI + +@Loggable(style: .static) +enum WindowFrameResolver { + typealias FrameResult = (frame: CGRect, sidesToAdjust: Edge.Set?) + + /// Convenience method that calculates a frame without requiring an external resize context. + /// Use this for UI previews, icon generation, and other cases that don't need to track resize state. + /// - Parameters: + /// - action: the window action to calculate the frame for. + /// - window: the window to be manipulated (can be nil for UI previews). + /// - bounds: the boundary within which the window should be manipulated. + /// - Returns: the computed frame (raw, without padding). + static func getFrame( + for action: WindowAction, + window: Window?, + bounds: CGRect + ) -> CGRect { + let context = ResizeContext(window: window, bounds: bounds, action: action) + return getFrame(resizeContext: context).frame + } + + /// Returns the frame for the specified window action using the provided resize context. + /// The returned frame is non-padded. Use `PaddingConfiguration.apply(to:bounds:action:window:)` to apply padding. + /// - Parameter resizeContext: the context containing window, screen, bounds, and tracking frame/edge adjustment state. + /// - Returns: a tuple containing the computed frame and the sides to adjust for grow/shrink actions. + static func getFrame(resizeContext: ResizeContext) -> FrameResult { + let action = resizeContext.action + let window = resizeContext.window + let bounds = resizeContext.paddedBounds + let direction = action.direction + + let noFrameActions: [WindowDirection] = [.noAction, .noSelection, .cycle, .minimize, .hide] + guard !noFrameActions.contains(direction), !direction.willFocusWindow else { + return (CGRect(origin: bounds.center, size: .zero), nil) + } + + var sidesToAdjust: Edge.Set? = if action.willManipulateExistingWindowFrame { + resizeContext.sidesToAdjust + } else { + nil + } + + var result: CGRect = calculateTargetFrame( + for: action, + window: window, + bounds: bounds, + sidesToAdjust: &sidesToAdjust, + resizeContext: resizeContext + ) + + if result.size.width < 0 || result.size.height < 0 || !result.isFinite { + result = CGRect(origin: bounds.center, size: .zero) + } + + return (result, sidesToAdjust) + } +} + +// MARK: - Calculators + +extension WindowFrameResolver { + /// Calculates the target frame for the specified window action based on the direction, window, bounds, and whether it is a preview. + /// - Parameters: + /// - action: the window action to calculate the frame for. + /// - window: the window to be manipulated. + /// - bounds: the bounds within which the window should be manipulated. + /// - sidesToAdjust: inout parameter for tracking which edges to adjust during grow/shrink actions. + /// - resizeContext: the context tracking frame and edge adjustment state. + /// - Returns: the calculated target frame for the specified window action. + private static func calculateTargetFrame( + for action: WindowAction, + window: Window?, + bounds: CGRect, + sidesToAdjust: inout Edge.Set?, + resizeContext: ResizeContext + ) -> CGRect { + let direction = action.direction + var result: CGRect = .zero + + if direction.frameMultiplyValues != nil { + result = applyFrameMultiplyValues(for: action, to: bounds) + + } else if direction.willAdjustSize { + // Can't grow or shrink a window that is not resizable + if let window, !window.isResizable { + return window.frame + } + + let frameToResizeFrom = resizeContext.cachedTargetFrame.raw + + // Compute which edges to adjust based on edges touching bounds + let edgesTouchingBounds = frameToResizeFrom.getEdgesTouchingBounds(bounds) + sidesToAdjust = .all.subtracting(edgesTouchingBounds) + + let proportional: [WindowDirection] = [.scaleUp, .scaleDown] + result = calculateSizeAdjustment( + for: action, + frameToResizeFrom: frameToResizeFrom, + bounds: bounds, + proportionalIfPossible: proportional.contains(direction), + sidesToAdjust: sidesToAdjust + ) + + } else if direction.willShrink || direction.willGrow { + // Can't grow or shrink a window that is not resizable + if let window, !window.isResizable { + return window.frame + } + + // This allows for control over each side + let frameToResizeFrom = resizeContext.cachedTargetFrame.raw + + // Compute which edges to adjust based on direction + switch direction { + case .shrinkTop, .growTop: + sidesToAdjust = .top + case .shrinkBottom, .growBottom: + sidesToAdjust = .bottom + case .shrinkLeft, .growLeft: + sidesToAdjust = .leading + case .shrinkHorizontal, .growHorizontal: + sidesToAdjust = [.leading, .trailing] + case .shrinkVertical, .growVertical: + sidesToAdjust = [.top, .bottom] + default: + sidesToAdjust = .trailing + } + + result = calculateSizeAdjustment( + for: action, + frameToResizeFrom: frameToResizeFrom, + bounds: bounds, + sidesToAdjust: sidesToAdjust + ) + + } else if direction.willMove { + let frameToResizeFrom = resizeContext.getTargetFrame().raw + + result = calculatePositionAdjustment(for: action, frameToResizeFrom: frameToResizeFrom) + + } else if direction.isCustomizable { + result = calculateCustomFrame(for: action, window: window, bounds: bounds) + + } else if direction == .center { + result = calculateCenterFrame(window: window, bounds: bounds) + + } else if direction == .macOSCenter { + result = calculateMacOSCenterFrame(window: window, bounds: bounds) + + } else if direction == .undo, let window { + result = getLastActionFrame(window: window, bounds: bounds) + + } else if direction == .initialFrame, let window { + result = getInitialFrame(window: window) + + } else if direction == .maximizeHeight, let window { + result = getMaximizeHeightFrame(window: window, bounds: bounds) + + } else if direction == .maximizeWidth, let window { + result = getMaximizeWidthFrame(window: window, bounds: bounds) + + } else if direction == .unstash, let window { + result = getInitialFrame(window: window) + + } else if direction == .fillAvailableSpace, let window { + result = getFillAvailableSpaceFrame(window: window) + } + + return result + } + + /// Applies the window direction's frame multiply values to the given bounds. + /// - Parameters: + /// - action: the window action containing the direction with frame multiply values. + /// - bounds: the bounds to which the frame multiply values will be applied on. + /// - Returns: a new `CGRect` with the frame multiply values applied. + private static func applyFrameMultiplyValues(for action: WindowAction, to bounds: CGRect) -> CGRect { + guard let frameMultiplyValues = action.direction.frameMultiplyValues else { + return .zero + } + + 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 + ) + } + + /// Calculates the user-specified custom frame relative to the provided bounds. + /// - Parameters: + /// - action: the window action containing custom frame parameters. + /// - window: the window to be manipulated. + /// - bounds: the bounds within which the window should be manipulated. + /// - Returns: the calculated custom frame based on the specified parameters. + private static func calculateCustomFrame(for action: WindowAction, window: Window?, bounds: CGRect) -> CGRect { + var result = CGRect(origin: bounds.origin, size: .zero) + + // Size Calculation + + if let sizeMode = action.sizeMode, sizeMode == .preserveSize, let window { + result.size = window.size + + } else if let sizeMode = action.sizeMode, sizeMode == .initialSize, let window { + if let initialFrame = WindowRecords.getInitialFrame(for: window) { + result.size = initialFrame.size + } + + } else { // sizeMode would be custom + switch action.unit { + case .pixels: + if window == nil { + let mainScreen = NSScreen.main ?? NSScreen.screens[0] + result.size.width = (CGFloat(action.width ?? .zero) / mainScreen.frame.width) * bounds.width + result.size.height = (CGFloat(action.height ?? .zero) / mainScreen.frame.height) * bounds.height + } else { + result.size.width = action.width ?? .zero + result.size.height = action.height ?? .zero + } + default: + if let width = action.width { + result.size.width = bounds.width * (width / 100.0) + } + + if let height = action.height { + result.size.height = bounds.height * (height / 100.0) + } + } + } + + // Position Calculation + + if let positionMode = action.positionMode, positionMode == .coordinates { + switch action.unit { + case .pixels: + if window == nil { + let mainScreen = NSScreen.main ?? NSScreen.screens[0] + result.origin.x = (CGFloat(action.xPoint ?? .zero) / mainScreen.frame.width) * bounds.width + result.origin.y = (CGFloat(action.yPoint ?? .zero) / mainScreen.frame.height) * bounds.height + } else { + // Note that bounds are ignored deliberately here + result.origin.x += action.xPoint ?? .zero + result.origin.y += action.yPoint ?? .zero + } + default: + if let xPoint = action.xPoint { + result.origin.x += bounds.width * (xPoint / 100.0) + } + + if let yPoint = action.yPoint { + result.origin.y += bounds.height * (yPoint / 100.0) + } + } + } else { // positionMode would be generic + switch action.anchor { + case .top: + result.origin.x = bounds.midX - result.width / 2 + case .topRight: + result.origin.x = bounds.maxX - result.width + case .right: + result.origin.x = bounds.maxX - result.width + result.origin.y = bounds.midY - result.height / 2 + case .bottomRight: + result.origin.x = bounds.maxX - result.width + result.origin.y = bounds.maxY - result.height + case .bottom: + result.origin.x = bounds.midX - result.width / 2 + result.origin.y = bounds.maxY - result.height + case .bottomLeft: + result.origin.y = bounds.maxY - result.height + case .left: + result.origin.y = bounds.midY - result.height / 2 + case .center: + result.origin.x = bounds.midX - result.width / 2 + result.origin.y = bounds.midY - result.height / 2 + case .macOSCenter: + let yOffset = getMacOSCenterYOffset(windowHeight: result.height, screenHeight: bounds.height) + result.origin.x = bounds.midX - result.width / 2 + result.origin.y = (bounds.midY - result.height / 2) + yOffset + default: + break + } + } + + return result + } + + /// Calculates the center frame for the window based on the provided bounds. The window's size will not be manipulated if a valid window is passed in. + /// - Parameters: + /// - window: the window to be centered. If `nil`, the center frame will be calculated based on the bounds (and therefore resized) + /// - bounds: the bounds within which the window should be centered. + /// - Returns: the calculated center frame for the window. + private static 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 + ) + } + + /// Calculates the "macOS center" frame for the window based on the provided bounds. The window's size will not be manipulated if a valid window is passed in. + /// + /// What is a "macOS center"? It is a center frame that is also shifted upwards by a certain amount, determined by the height of the window and the screen height. + /// Fun fact: this behavior can also be reproduced in your own NSWindows by calling its `center()` method! + /// + /// - Parameters: + /// - window: the window to be centered. If `nil`, the center frame will be calculated based on the bounds (and therefore resized) + /// - bounds: the bounds within which the window should be centered. + /// - Returns: the calculated "macOS center" frame for the window. + private static 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 = getMacOSCenterYOffset( + windowHeight: windowSize.height, + screenHeight: bounds.height + ) + + return CGRect( + origin: CGPoint( + x: bounds.midX - (windowSize.width / 2), + y: bounds.midY - (windowSize.height / 2) + yOffset + ), + size: windowSize + ) + } + + /// This static 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. + private static func getMacOSCenterYOffset(windowHeight: CGFloat, screenHeight: CGFloat) -> CGFloat { + let halfScreenHeight = screenHeight / 2 + let windowHeightPercent = windowHeight / screenHeight + return (0.5 * windowHeightPercent - 0.5) * halfScreenHeight + } + + /// Retrieves the last action frame for the specified window, based on the last action recorded in `WindowRecords`. + /// - Parameters: + /// - window: the window for which the last action frame is to be retrieved. + /// - bounds: the bounds within which the window should be manipulated. + /// - Returns: the frame of the last action performed on the window, or the current frame if no last action is found. + private static func getLastActionFrame(window: Window, bounds: CGRect) -> CGRect { + if let previousAction = WindowRecords.getLastAction(for: window) { + log.info("Last action was \(previousAction.description)") + + return WindowFrameResolver.getFrame( + for: previousAction, + window: window, + bounds: bounds + ) + } else { + log.info("Didn't find frame to undo; using current frame") + return window.frame + } + } + + /// Retrieves the initial frame for the specified window, based on the initial frame recorded in `WindowRecords`. + /// - Parameter window: the window for which the initial frame is to be retrieved. + /// - Returns: the initial frame of the window, or the current frame if no initial frame is found. + private static func getInitialFrame(window: Window) -> CGRect { + if let initialFrame = WindowRecords.getInitialFrame(for: window) { + return initialFrame + } else { + log.info("Didn't find initial frame; using current frame") + return window.frame + } + } + + /// Computes a new window frame with the maximum height that fits within the given bounds. + /// - Parameters: + /// - window: the window whose current frame is used as a reference. + /// - bounds: the area within which the window should be resized. + /// - Returns: a CGRect representing a frame that maximizes the window's height. + private static func getMaximizeHeightFrame(window: Window, bounds: CGRect) -> CGRect { + CGRect( + x: window.frame.minX, + y: bounds.minY, + width: window.frame.width, + height: bounds.height + ) + } + + /// Computes a new window frame with the maximum width that fits within the given bounds. + /// - Parameters: + /// - window: the window whose current frame is used as a reference. + /// - bounds: the area within which the window should be resized. + /// - Returns: a CGRect representing a frame that maximizes the window's width. + private static func getMaximizeWidthFrame(window: Window, bounds: CGRect) -> CGRect { + CGRect( + x: bounds.minX, + y: window.frame.minY, + width: bounds.width, + height: window.frame.height + ) + } + + /// Computes a new window frame that takes up the most area, without overlapping with other windows. + /// Other windows that already overlap with the current window will be ignored. + /// - Parameter window: the window whose current frame is used as a reference. + /// - Returns: a CGRect representing a frame that makes a window fill the most available space. + private static func getFillAvailableSpaceFrame(window: Window) -> CGRect { + let currentFrame = window.frame + + guard let screen = ScreenUtility.screenContaining(window) ?? NSScreen.main else { return currentFrame } + let screenFrame = screen.cgSafeScreenFrame + + let nonIntersectingWindowFrames = WindowUtility.windowList() + .map(\.frame) + .filter { !$0.intersects(currentFrame) } // Ensure it doesn't intersect with the current window + .map { $0.intersection(screenFrame) } // Crop it to the screen frame + + // Computes the closest window obstacle in each of the four cardinal directions + // (left, right, top, bottom) relative to the current window, and returns the boundaries + // formed by these obstacles, constrained to the screen frame. + func computeBoundaries() -> (minX: CGFloat, minY: CGFloat, maxX: CGFloat, maxY: CGFloat) { + var minX = screenFrame.minX + var minY = screenFrame.minY + var maxX = screenFrame.maxX + var maxY = screenFrame.maxY + + for frame in nonIntersectingWindowFrames { + if frame.maxX <= currentFrame.minX { minX = max(minX, frame.maxX) } + if frame.maxY <= currentFrame.minY { minY = max(minY, frame.maxY) } + if frame.minX >= currentFrame.maxX { maxX = min(maxX, frame.minX) } + if frame.minY >= currentFrame.maxY { maxY = min(maxY, frame.minY) } + } + + return (minX, minY, maxX, maxY) + } + + let (minX, minY, maxX, maxY) = computeBoundaries() + + // Needed for Hashable conformance + struct Boundary: Hashable { + let min: CGFloat + let max: CGFloat + } + + let uniqueXBoundaries: Set = [ + Boundary(min: minX, max: maxX), // Respect obstacles in both directions + Boundary(min: currentFrame.minX, max: maxX), // Keep left, expand right + Boundary(min: minX, max: currentFrame.maxX), // Expand left, keep right + Boundary(min: currentFrame.minX, max: screenFrame.maxX), // Keep left, expand right to screen edge + Boundary(min: screenFrame.minX, max: currentFrame.maxX), // Expand left to screen edge, keep right + Boundary(min: screenFrame.minX, max: screenFrame.maxX) // Full screen width + ] + + let uniqueYBoundaries: Set = [ + Boundary(min: minY, max: maxY), // Respect obstacles in both directions + Boundary(min: currentFrame.minY, max: maxY), // Keep bottom, expand top + Boundary(min: minY, max: currentFrame.maxY), // Expand bottom, keep top + Boundary(min: currentFrame.minY, max: screenFrame.maxY), // Keep bottom, expand top to screen edge + Boundary(min: screenFrame.minY, max: currentFrame.maxY), // Expand bottom to screen edge, keep top + Boundary(min: screenFrame.minY, max: screenFrame.maxY) // Full screen height + ] + + // Generate all possible combinations of x/y boundaries and filter it to valid candidates. + // A candidate is valid if it doesn't overlap with any other window. + let validCandidates = uniqueXBoundaries.flatMap { xBound in + uniqueYBoundaries.compactMap { yBound in + let combination = CGRect( + x: xBound.min, + y: yBound.min, + width: xBound.max - xBound.min, + height: yBound.max - yBound.min + ) + + return nonIntersectingWindowFrames.allSatisfy { !$0.intersects(combination) } ? combination : nil + } + } + + return validCandidates.max { $0.size.area < $1.size.area } ?? currentFrame + } + + /// Calculates the size adjustment for the specified frame based on the bounds and the direction of the action. + /// - Parameters: + /// - action: the window action containing the direction. + /// - frameToResizeFrom: the frame to apply the size adjustment to. + /// - bounds: the bounds within which the frame should be resized. + /// - proportionalIfPossible: if true and all edges are resized, scales proportionally about the center instead of insetting each side. + /// - sidesToAdjust: which edges to adjust during the resize. + /// - Returns: the adjusted frame after applying the size adjustment based on the direction and bounds. + private static func calculateSizeAdjustment( + for action: WindowAction, + frameToResizeFrom: CGRect, + bounds: CGRect, + proportionalIfPossible: Bool = false, + sidesToAdjust: Edge.Set? + ) -> CGRect { + let direction = action.direction + let step = Defaults[.sizeIncrement] * ((direction == .larger || direction == .scaleUp || direction.willGrow) ? -1 : 1) + + let previewPadding = Defaults[.previewPadding] + let minSize = CGSize( + width: previewPadding + 100, + height: previewPadding + 100 + ) + + func insetAllEdges(_ rect: CGRect) -> CGRect { + rect.inset(by: step, minSize: minSize) + } + + func scaleAllEdgesIfPossible(_ rect: CGRect) -> CGRect? { + guard proportionalIfPossible, rect.width > 0, rect.height > 0 else { return nil } + + let sx = (rect.width - 2 * step) / rect.width + let sy = (rect.height - 2 * step) / rect.height + var targetUniformScale = min(sx, sy) + + guard targetUniformScale.isFinite, targetUniformScale > 0 else { return nil } + let minScaleToSatisfyMinWidth = minSize.width / rect.width + let minScaleToSatisfyMinHeight = minSize.height / rect.height + let minUniformScale = max(minScaleToSatisfyMinWidth, minScaleToSatisfyMinHeight) + targetUniformScale = max(targetUniformScale, minUniformScale) + + let rectCenter = CGPoint( + x: rect.midX, + y: rect.midY + ) + + let scaledSize = CGSize( + width: rect.width * targetUniformScale, + height: rect.height * targetUniformScale + ) + + let scaledRect = CGRect( + x: rectCenter.x - scaledSize.width / 2, + y: rectCenter.y - scaledSize.height / 2, + width: scaledSize.width, + height: scaledSize.height + ) + + return scaledRect + } + + var result = frameToResizeFrom + + if let edges = sidesToAdjust { + let resizeAllEdges = edges.isEmpty || edges.contains(.all) + + if resizeAllEdges { + result = scaleAllEdgesIfPossible(result) ?? insetAllEdges(result) + } else { + result = result.padding(edges, step) + + if result.width < minSize.width { + result.size.width = minSize.width + result.origin.x = frameToResizeFrom.midX - minSize.width / 2 + } + if result.height < minSize.height { + result.size.height = minSize.height + result.origin.y = frameToResizeFrom.midY - minSize.height / 2 + } + } + } + + result = result + .intersection(bounds) + + if result.size.approximatelyEqual(to: frameToResizeFrom.size, tolerance: 2) { + result = frameToResizeFrom + } + + return result + } + + /// Calculates the position adjustment for the specified frame based on the direction of the action. + /// - Parameters: + /// - action: the window action containing the direction. + /// - frameToResizeFrom: the frame to apply the position adjustment to. + /// - Returns: the adjusted frame after applying the position adjustment based on the direction. + private static func calculatePositionAdjustment(for action: WindowAction, frameToResizeFrom: CGRect) -> CGRect { + let direction = action.direction + var result = frameToResizeFrom + + if direction == .moveUp { + result.origin.y -= Defaults[.sizeIncrement] + } else if direction == .moveDown { + result.origin.y += Defaults[.sizeIncrement] + } else if direction == .moveRight { + result.origin.x += Defaults[.sizeIncrement] + } else if direction == .moveLeft { + result.origin.x -= Defaults[.sizeIncrement] + } + + return result + } +} diff --git a/Loop/Window Management/Window Manipulation/WindowRecords.swift b/Loop/Window Management/Window Manipulation/WindowRecords.swift index bf4b89c9..3a0bbae5 100644 --- a/Loop/Window Management/Window Manipulation/WindowRecords.swift +++ b/Loop/Window Management/Window Manipulation/WindowRecords.swift @@ -8,6 +8,7 @@ import Scribe import SwiftUI +@Loggable(style: .static) enum WindowRecords { private static var recordsByWindowID: [CGWindowID: WindowRecords.Record] = [:] @@ -30,21 +31,21 @@ enum WindowRecords { } recordsByWindowID[window.cgWindowID] = nil - Log.success("Erased records for: \(window)", category: .windowRecords) + log.success("Erased records for: \(window)") } static func recordFirstIfNeeded(for window: Window) { guard recordsByWindowID[window.cgWindowID] == nil else { return } recordsByWindowID[window.cgWindowID] = Record(initialFrame: window.frame) - Log.info("Recorded first for: \(window)", category: .windowRecords) + log.info("Recorded first for: \(window)") } /// Determines if an action should be recorded using its frame instead of the action applied onto it. /// - Parameter action: the action to apply onto the window. /// - Returns: Whether this action should be recorded with its final frame instead of using the action. static func shouldStoreAsFinalFrame(_ action: WindowAction) -> Bool { - /// Actions that are stored as frames need to be recorded *after* resize. - /// These actions are context-dependent, and cannot simply be called as an action to restore the previous state. + // Actions that are stored as frames need to be recorded *after* resize. + // These actions are context-dependent, and cannot simply be called as an action to restore the previous state. let storeAsFrame = action.direction.willChangeScreen || action.willManipulateExistingWindowFrame return storeAsFrame } @@ -54,7 +55,7 @@ enum WindowRecords { /// - window: Window to record /// - action: WindowAction to record static func record(_ window: Window, _ action: WindowAction) { - /// If the window has not been recorded, record it + // If the window has not been recorded, record it recordFirstIfNeeded(for: window) // There is no point in recording undo @@ -65,9 +66,9 @@ enum WindowRecords { if shouldStoreAsFinalFrame(action), let screen = ScreenUtility.screenContaining(window) { let customActionName = "autogenerated_record_\(action.getName())" let windowFrame = window.frame - let adjustedBounds = PaddingSettings - .configuredPadding(for: screen) - .apply(onScreenFrame: screen.safeScreenFrame) + let adjustedBounds = PaddingConfiguration + .getConfiguredPadding(for: screen) + .applyToBounds(screen.cgSafeScreenFrame) let proportionalSize = CGRect( x: (windowFrame.minX - adjustedBounds.minX) / adjustedBounds.width, @@ -95,7 +96,7 @@ enum WindowRecords { recordsByWindowID[window.cgWindowID]?.actions.insert(action, at: 0) } - Log.info("Recorded: \(action) for: \(window)", category: .windowRecords) + log.info("Recorded: \(action) for: \(window)") } /// Removes the last action performed on the specified window. This will NOT remove the first action for the specified window. @@ -103,13 +104,13 @@ enum WindowRecords { guard let record = recordsByWindowID[window.cgWindowID], record.actions.count > 1 else { - Log.info("Skipped removing last record for: \(window)", category: .windowRecords) + log.info("Skipped removing last record for: \(window)") return } recordsByWindowID[window.cgWindowID]?.actions.removeFirst() - Log.info("Removed last record for: \(window)", category: .windowRecords) + log.info("Removed last record for: \(window)") } /// This window's last action diff --git a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift index 0153422a..a6c3d24e 100644 --- a/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift +++ b/Loop/Window Management/Window Manipulation/WindowTransformAnimation.swift @@ -7,7 +7,7 @@ import SwiftUI -// Animate a window's resize! +/// Animate a window's resize! final class WindowTransformAnimation: NSAnimation { private var targetFrame: CGRect private let originalFrame: CGRect diff --git a/Loop/Window Management/Window/Window.swift b/Loop/Window Management/Window/Window.swift index 349ee287..9f5a32ce 100644 --- a/Loop/Window Management/Window/Window.swift +++ b/Loop/Window Management/Window/Window.swift @@ -29,6 +29,7 @@ enum WindowError: LocalizedError { } } +@Loggable final class Window { let axWindow: AXUIElement let cgWindowID: CGWindowID @@ -119,7 +120,7 @@ final class Window { } return NSAccessibility.Role(rawValue: value) } catch { - Log.error("Failed to get role: \(error.localizedDescription)", category: .window) + log.error("Failed to get role: \(error.localizedDescription)") return nil } } @@ -131,7 +132,7 @@ final class Window { } return NSAccessibility.Subrole(rawValue: value) } catch { - Log.error("Failed to get subrole: \(error.localizedDescription)", category: .window) + log.error("Failed to get subrole: \(error.localizedDescription)") return nil } } @@ -140,7 +141,7 @@ final class Window { do { return try axWindow.getValue(.title) } catch { - Log.error("Failed to get title: \(error.localizedDescription)", category: .window) + log.error("Failed to get title: \(error.localizedDescription)") return nil } } @@ -155,7 +156,7 @@ final class Window { let result: Bool? = try appWindow.getValue(.enhancedUserInterface) return result ?? false } catch { - Log.error("Failed to get enhancedUserInterface: \(error.localizedDescription)", category: .window) + log.error("Failed to get enhancedUserInterface: \(error.localizedDescription)") return false } } @@ -167,7 +168,7 @@ final class Window { let appWindow = AXUIElementCreateApplication(pid) try appWindow.setValue(.enhancedUserInterface, value: newValue) } catch { - Log.error("Failed to set enhancedUserInterface: \(error.localizedDescription)", category: .window) + log.error("Failed to set enhancedUserInterface: \(error.localizedDescription)") } } } @@ -182,7 +183,7 @@ final class Window { try? axWindow.performAction(.raise) - /// See: https://github.com/yresk/alt-tab-macos/blob/5b8a9110dbdb9b4802a8a85ee1469427fbc192e8/alt-tab-macos/api-wrappers/AXUIElement.swift#L60 + // See: https://github.com/yresk/alt-tab-macos/blob/5b8a9110dbdb9b4802a8a85ee1469427fbc192e8/alt-tab-macos/api-wrappers/AXUIElement.swift#L60 if let pid = try? axWindow.getPID() { _ = SkyLightToolBelt.makeKeyWindow( windowID: cgWindowID, @@ -217,7 +218,7 @@ final class Window { let result: NSNumber? = try axWindow.getValue(.fullScreen) return result?.boolValue ?? false } catch { - Log.error("Failed to get fullscreen: \(error.localizedDescription)", category: .window) + log.error("Failed to get fullscreen: \(error.localizedDescription)") return false } } @@ -225,7 +226,7 @@ final class Window { do { try axWindow.setValue(.fullScreen, value: newValue) } catch { - Log.error("Failed to set fullscreen: \(error.localizedDescription)", category: .window) + log.error("Failed to set fullscreen: \(error.localizedDescription)") } } } @@ -292,7 +293,7 @@ final class Window { let result: NSNumber? = try axWindow.getValue(.minimized) return result?.boolValue ?? false } catch { - Log.error("Failed to get minimized: \(error.localizedDescription)", category: .window) + log.error("Failed to get minimized: \(error.localizedDescription)") return false } } @@ -300,7 +301,7 @@ final class Window { do { try axWindow.setValue(.minimized, value: newValue) } catch { - Log.error("Failed to set minimized: \(error.localizedDescription)", category: .window) + log.error("Failed to set minimized: \(error.localizedDescription)") } } } @@ -317,7 +318,7 @@ final class Window { } return result } catch { - Log.error("Failed to get position: \(error.localizedDescription)", category: .window) + log.error("Failed to get position: \(error.localizedDescription)") return .zero } } @@ -325,7 +326,7 @@ final class Window { do { try axWindow.setValue(.position, value: newValue) } catch { - Log.error("Failed to set position: \(error.localizedDescription)", category: .window) + log.error("Failed to set position: \(error.localizedDescription)") } } } @@ -338,7 +339,7 @@ final class Window { } return result } catch { - Log.error("Failed to get size: \(error.localizedDescription)", category: .window) + log.error("Failed to get size: \(error.localizedDescription)") return .zero } } @@ -346,7 +347,7 @@ final class Window { do { try axWindow.setValue(.size, value: newValue) } catch { - Log.error("Failed to set size: \(error.localizedDescription)", category: .window) + log.error("Failed to set size: \(error.localizedDescription)") } } } @@ -356,7 +357,7 @@ final class Window { let result: Bool = try axWindow.canSetValue(.size) return result } catch { - Log.error("Failed to determine if window size can be set: \(error.localizedDescription)", category: .window) + log.error("Failed to determine if window size can be set: \(error.localizedDescription)") return true } } @@ -373,7 +374,7 @@ final class Window { if enhancedUI { let appName = nsRunningApplication?.localizedName - Log.info("\(appName ?? "This app")'s enhanced UI will be temporarily disabled while resizing.", category: .window) + log.info("\(appName ?? "This app")'s enhanced UI will be temporarily disabled while resizing.") enhancedUserInterface = false } @@ -388,6 +389,7 @@ final class Window { } } + @concurrent func setFrameAnimated( _ rect: CGRect, bounds: CGRect @@ -396,7 +398,7 @@ final class Window { if enhancedUI { let appName = nsRunningApplication?.localizedName - Log.info("\(appName ?? "This app")'s enhanced UI will be temporarily disabled while resizing.", category: .window) + log.info("\(appName ?? "This app")'s enhanced UI will be temporarily disabled while resizing.") enhancedUserInterface = false } diff --git a/Loop/Window Management/Window/WindowUtility+FocusNavigation.swift b/Loop/Window Management/Window/WindowUtility+FocusNavigation.swift index c4d16b28..ee701f77 100644 --- a/Loop/Window Management/Window/WindowUtility+FocusNavigation.swift +++ b/Loop/Window Management/Window/WindowUtility+FocusNavigation.swift @@ -25,12 +25,12 @@ extension WindowUtility { from: currentWindow, direction: direction ) else { - Log.info("No window found to focus in direction \(direction)", category: .windowUtility) + log.info("No window found to focus in direction \(direction)") return nil } let nextWindowTitle = directionalWindow.nsRunningApplication?.localizedName ?? directionalWindow.title ?? "" - Log.info("Focusing window: \(nextWindowTitle)", category: .windowUtility) + log.info("Focusing window: \(nextWindowTitle)") Task { @MainActor in directionalWindow.focus() @@ -41,12 +41,12 @@ extension WindowUtility { static func focusNextWindowInStack(from currentWindow: Window?) -> Window? { guard let directionalWindow = WindowUtility.nextStackedWindow(from: currentWindow) else { - Log.info("No window found to focus in stack", category: .windowUtility) + log.info("No window found to focus in stack") return nil } let nextWindowTitle = directionalWindow.nsRunningApplication?.localizedName ?? directionalWindow.title ?? "" - Log.info("Focusing window: \(nextWindowTitle)", category: .windowUtility) + log.info("Focusing window: \(nextWindowTitle)") Task { @MainActor in directionalWindow.focus() @@ -74,7 +74,7 @@ extension WindowUtility { } guard !availableWindows.isEmpty else { - Log.info("No windows available to focus", category: .windowUtility) + log.info("No windows available to focus") return nil } @@ -84,7 +84,7 @@ extension WindowUtility { .filter { $0.cgWindowID != currentWindow.cgWindowID } guard !otherWindows.isEmpty else { - Log.info("No other windows available to focus", category: .windowUtility) + log.info("No other windows available to focus") return nil } @@ -95,20 +95,20 @@ extension WindowUtility { direction: direction, canWrap: true ) { - Log.info("Found window to focus in direction \(direction): \(nextWindow.description)", category: .windowUtility) + log.info("Found window to focus in direction \(direction): \(nextWindow.description)") return nextWindow } else { - Log.info("No window found in direction \(direction)", category: .windowUtility) + log.info("No window found in direction \(direction)") return nil } } else { guard let screen = NSScreen.screenWithMouse ?? NSScreen.main else { - Log.error("Could not determine active screen", category: .windowUtility) + log.error("Could not determine active screen") return nil } - let screenCenter = screen.safeScreenFrame.center - Log.info("Navigating from screen center: \(screenCenter.debugDescription)", category: .windowUtility) + let screenCenter = screen.cgSafeScreenFrame.center + log.info("Navigating from screen center: \(screenCenter.debugDescription)") // Find the closest window in the specified direction from screen center let nextWindow = availableWindows @@ -116,9 +116,9 @@ extension WindowUtility { .min { screenCenter.distance(to: $0.frame.center) < screenCenter.distance(to: $1.frame.center) } if let nextWindow { - Log.info("Found window to focus in direction \(direction): \(nextWindow.description)", category: .windowUtility) + log.info("Found window to focus in direction \(direction): \(nextWindow.description)") } else { - Log.info("No window found in direction \(direction) from screen center", category: .windowUtility) + log.info("No window found in direction \(direction) from screen center") } return nextWindow @@ -142,7 +142,7 @@ extension WindowUtility { } guard !availableWindows.isEmpty else { - Log.info("No windows available to focus", category: .windowUtility) + log.info("No windows available to focus") return nil } @@ -150,7 +150,7 @@ extension WindowUtility { // If no current window, return the last available window let targetWindow = availableWindows.last if let targetWindow { - Log.info("No current window, selecting last window: \(targetWindow.description)", category: .windowUtility) + log.info("No current window, selecting last window: \(targetWindow.description)") } return targetWindow } @@ -160,7 +160,7 @@ extension WindowUtility { .filter { $0.cgWindowID != currentWindow.cgWindowID } guard !otherWindows.isEmpty else { - Log.info("No other windows available to focus in stack", category: .windowUtility) + log.info("No other windows available to focus in stack") return nil } @@ -169,10 +169,10 @@ extension WindowUtility { from: currentWindow, in: otherWindows ) { - Log.info("Found window to focus in stack: \(nextWindow.description)", category: .windowUtility) + log.info("Found window to focus in stack: \(nextWindow.description)") return nextWindow } else { - Log.info("No window found in stack", category: .windowUtility) + log.info("No window found in stack") return nil } }