diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 5cc2da5b..b9fb0106 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -95,7 +95,6 @@ buildConfigurationList = A8E59C44297F5E9B0064D4BA /* Build configuration list for PBXNativeTarget "Loop" */; buildPhases = ( A8E59C31297F5E9A0064D4BA /* Sources */, - A80FD0D82CB34BA300DCC00B /* Run SwiftFormat */, A8E59C32297F5E9A0064D4BA /* Frameworks */, A8E59C33297F5E9A0064D4BA /* Resources */, ); @@ -175,28 +174,6 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - A80FD0D82CB34BA300DCC00B /* Run SwiftFormat */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Run SwiftFormat"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Default Homebrew installation path on Intel-based Macs\nHOMEBREW_INTEL_PATH=\"/usr/local/bin/swiftformat\"\n\n# Default Homebrew installation path on Apple Silicon Macs\nHOMEBREW_ARM_PATH=\"/opt/homebrew/bin/swiftformat\"\n\n# Determine the architecture of the machine (arm64 or x86_64)\nARCH=$(uname -m)\n\n# Set the Homebrew path based on the architecture\nif [ \"$ARCH\" = \"arm64\" ]; then\n SWIFTFORMAT_PATH=\"$HOMEBREW_ARM_PATH\"\nelse\n SWIFTFORMAT_PATH=\"$HOMEBREW_INTEL_PATH\"\nfi\n\n# Check if SwiftFormat is installed via Homebrew\nif [ -x \"$SWIFTFORMAT_PATH\" ]; then\n \"$SWIFTFORMAT_PATH\" .\nelse\n echo \"warning: SwiftFormat not installed via Homebrew or not found in expected paths\"\nfi\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ A8E59C31297F5E9A0064D4BA /* Sources */ = { isa = PBXSourcesBuildPhase; diff --git a/Loop/App/DataPatcher.swift b/Loop/App/DataPatcher.swift index 4b73c6d8..5fb6f1bd 100644 --- a/Loop/App/DataPatcher.swift +++ b/Loop/App/DataPatcher.swift @@ -11,9 +11,9 @@ import Scribe enum DataPatcher { static func run() { - let initialPatches = Defaults[.patchesApplied] + let initialPatches: Patches = Defaults[.patchesApplied] - if !initialPatches.contains(.accentColorMode) { + runPatch(patch: .changeToAccentColorMode, initial: initialPatches) { // Migrate to accent color mode // We need to migrate `useSystemAccentColor` and `processWallpaper` over to `accentColorMode` let useSystemAccentColor: Bool = Defaults[.useSystemAccentColor] @@ -27,14 +27,28 @@ enum DataPatcher { Defaults[.accentColorMode] = .custom } - Defaults[.patchesApplied].formUnion(.accentColorMode) - Log.info("Ran patch accentColorMode", category: .dataPatcher) + Defaults.reset(.useSystemAccentColor) + Defaults.reset(.processWallpaper) + } + + runPatch(patch: .removeRevealedStashedWindows, initial: initialPatches) { + Defaults.reset(.stashManagerRevealedWindows) + } + } + + private static func runPatch(patch: Patches, initial: Patches, with callback: () -> ()) { + if !initial.contains(patch) { + callback() + + Defaults[.patchesApplied].formUnion(patch) + Log.info("Ran patch \(patch)", category: .dataPatcher) } } - struct Patch: OptionSet, Defaults.Serializable { + struct Patches: OptionSet, Defaults.Serializable { let rawValue: Int - static let accentColorMode = Self(rawValue: 1 << 0) + static let changeToAccentColorMode = Self(rawValue: 1 << 0) + static let removeRevealedStashedWindows = Self(rawValue: 1 << 1) } } diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index b1ab8285..26c06cd9 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -84,7 +84,7 @@ final class LoopManager: ObservableObject { // MARK: - Opening/Closing Loop extension LoopManager { - private func openLoop(startingAction: WindowAction?) { + private func openLoop(startingAction: WindowAction) { guard AccessibilityManager.shared.isGranted else { return } @@ -96,9 +96,7 @@ extension LoopManager { /// In these cases, we can simply update the action instead of reopening the Loop. /// Enabling keybindObserver was considered as a workaround, but it doesn't start quickly enough. /// Although Karabiner-Elements sends key events separately, they arrive in quick succession. - if let startingAction { - changeAction(startingAction, disableHapticFeedback: true) - } + changeAction(startingAction, disableHapticFeedback: true) return } @@ -111,7 +109,7 @@ extension LoopManager { return } - Log.info("Opening Loop with starting action: \(startingAction?.description ?? "(none)") and target window: \(window?.description ?? "(none)")", category: .loopManager) + Log.info("Opening Loop with starting action: \(startingAction.description) and target window: \(window?.description ?? "(none)")", category: .loopManager) // Record the first frame in advance if the preview window is disabled if let window, @@ -152,10 +150,7 @@ extension LoopManager { } isLoopActive = true - - if let startingAction { - changeAction(startingAction, disableHapticFeedback: true) - } + changeAction(startingAction, disableHapticFeedback: true) } private func closeLoop(forceClose: Bool) { @@ -196,7 +191,7 @@ extension LoopManager { LoopManager.lastTargetFrame = .zero } - private func openWindows(startingAction: WindowAction?, window: Window?) { + private func openWindows(startingAction: WindowAction, window: Window?) { if Defaults[.previewVisibility], let screenToResizeOn { previewController.open( screen: screenToResizeOn, @@ -236,7 +231,7 @@ extension LoopManager { canAdvanceCycle: Bool = true ) { guard - !currentAction.isSameManipulation(as: newAction) || newAction.shouldImmediatelyExecuteAction, + currentAction.id != newAction.id || newAction.shouldImmediatelyExecuteAction, isLoopActive, let currentScreen = screenToResizeOn else { diff --git a/Loop/Core/Observers/Helpers/DoubleClickTimer.swift b/Loop/Core/Observers/Helpers/DoubleClickTimer.swift index ce029392..12edd70b 100644 --- a/Loop/Core/Observers/Helpers/DoubleClickTimer.swift +++ b/Loop/Core/Observers/Helpers/DoubleClickTimer.swift @@ -14,18 +14,18 @@ import Defaults /// two occur within the system-defined (and user-customizable) `NSEvent.doubleClickInterval`. final class DoubleClickTimer { private var lastTriggerKeyPressTime: Date? - private let openCallback: (WindowAction?) -> () + private let openCallback: (WindowAction) -> () private var doubleClickInterval: TimeInterval { NSEvent.doubleClickInterval } /// Creates a new `DoubleClickTimer` instance with the specified callback to invoke on a double-press event. /// - Parameter openCallback: A closure that is called when a double-click is detected. The closure receives the `WindowAction` associated with the trigger as its parameter. - init(openCallback: @escaping (WindowAction?) -> ()) { + init(openCallback: @escaping (WindowAction) -> ()) { self.openCallback = openCallback } /// Handles a trigger event (such as a key press) and determines whether it qualifies as a "double-click". /// - Parameter startingAction: The `WindowAction` associated with the trigger. - func handleTrigger(startingAction: WindowAction?) { + func handleTrigger(startingAction: WindowAction) { let now = Date() // If we detect a double-press, trigger immediately. Otherwise, just record the time diff --git a/Loop/Core/Observers/Helpers/TriggerDelayTimer.swift b/Loop/Core/Observers/Helpers/TriggerDelayTimer.swift index 2ab8e70c..3c662bf3 100644 --- a/Loop/Core/Observers/Helpers/TriggerDelayTimer.swift +++ b/Loop/Core/Observers/Helpers/TriggerDelayTimer.swift @@ -16,8 +16,8 @@ import Foundation /// In that case, use the `updateStartingAction` method. final class TriggerDelayTimer { private var triggerDelayTimer: Task<(), Never>? - private var startingAction: WindowAction? - private let openCallback: (WindowAction?) -> () + private var startingAction: WindowAction = .init(.noSelection) + private let openCallback: (WindowAction) -> () private var triggerDelay: CGFloat { Defaults[.triggerDelay] } /// Indicates whether the delay timer is currently active. @@ -25,7 +25,7 @@ final class TriggerDelayTimer { /// Creates a new `TriggerDelayTimer` instance with the specified callback to invoke after a user-configured delay has elapsed. /// - Parameter openCallback: A closure that is called once the trigger delay completes successfully. The closure receives the latest `WindowAction` depending on what has been set. - init(openCallback: @escaping (WindowAction?) -> ()) { + init(openCallback: @escaping (WindowAction) -> ()) { self.openCallback = openCallback } @@ -38,7 +38,7 @@ final class TriggerDelayTimer { /// If another trigger is received before the delay elapses, the previous timer is canceled and restarted. /// Once the configured delay duration passes without interruption, the provided callback is invoked, with the latest inputted starting action. /// - Parameter startingAction: The `WindowAction` associated with the trigger. - func handleTrigger(startingAction action: WindowAction?) { + func handleTrigger(startingAction action: WindowAction) { startingAction = action cancel() // Ensure no previous timer is active @@ -53,7 +53,7 @@ final class TriggerDelayTimer { /// Updates the stored `startingAction` value without restarting the timer. To be used with keybinds. /// - Parameter newAction: The new `WindowAction` to associate with the current trigger delay. - func updateStartingAction(with newAction: WindowAction?) { + func updateStartingAction(with newAction: WindowAction) { startingAction = newAction } @@ -61,6 +61,6 @@ final class TriggerDelayTimer { func cancel() { triggerDelayTimer?.cancel() triggerDelayTimer = nil - startingAction = nil + startingAction = .init(.noSelection) } } diff --git a/Loop/Core/Observers/KeybindTrigger.swift b/Loop/Core/Observers/KeybindTrigger.swift index 893137f0..0c28b552 100644 --- a/Loop/Core/Observers/KeybindTrigger.swift +++ b/Loop/Core/Observers/KeybindTrigger.swift @@ -13,7 +13,7 @@ import Defaults final class KeybindTrigger { // Parameters private let windowActionCache: WindowActionCache - private let openCallback: (WindowAction?) -> () + private let openCallback: (WindowAction) -> () private let closeCallback: (Bool) -> () private let checkIfLoopOpen: () -> Bool @@ -58,7 +58,7 @@ final class KeybindTrigger { /// - closeCallback: what to do when the trigger key is released, and Loop should be closed. init( windowActionCache: WindowActionCache, - openCallback: @escaping (WindowAction?) -> (), + openCallback: @escaping (WindowAction) -> (), closeCallback: @escaping (Bool) -> (), checkIfLoopOpen: @escaping () -> Bool ) { @@ -193,7 +193,10 @@ final class KeybindTrigger { // Only trigger Loop without an action if the only pressed keys perfectly matches the trigger key. if allPressedKeys == triggerKey { - openLoop(startingAction: nil, overrideExistingTriggerDelayTimerAction: !isARepeat) + openLoop( + startingAction: .init(.noSelection), + overrideExistingTriggerDelayTimerAction: !isARepeat + ) return .opening } } else { @@ -205,7 +208,7 @@ final class KeybindTrigger { return .forward } - private func openLoop(startingAction: WindowAction?, overrideExistingTriggerDelayTimerAction: Bool) { + private func openLoop(startingAction: WindowAction, overrideExistingTriggerDelayTimerAction: Bool) { if checkIfLoopOpen() { openCallback(startingAction) // Only update Loop to the latest WindowAction } else { @@ -230,7 +233,7 @@ final class KeybindTrigger { } private func startTriggerDelayTimer( - startingAction: WindowAction?, + startingAction: WindowAction, overrideExistingTriggerDelayTimerAction: Bool ) { // If a trigger delay timer is already active, only update its startingAction when diff --git a/Loop/Core/Observers/MiddleClickTrigger.swift b/Loop/Core/Observers/MiddleClickTrigger.swift index a4c6a6cb..a185763c 100644 --- a/Loop/Core/Observers/MiddleClickTrigger.swift +++ b/Loop/Core/Observers/MiddleClickTrigger.swift @@ -11,7 +11,7 @@ import Defaults /// Reads middle-click events using a PassiveEventMonitor, and triggers Loop open/close callbacks, when appropriate. final class MiddleClickTrigger { // Callbacks - private let openCallback: (WindowAction?) -> () + private let openCallback: (WindowAction) -> () private let closeCallback: (Bool) -> () // State-tracking @@ -27,7 +27,7 @@ final class MiddleClickTrigger { guard let self else { return } if useTriggerDelay { - triggerDelayTimer.handleTrigger(startingAction: nil) + triggerDelayTimer.handleTrigger(startingAction: .init(.noSelection)) } else { openCallback(action) } @@ -38,7 +38,7 @@ final class MiddleClickTrigger { /// - openCallback: what to do when the middle mouse button is pressed, and Loop should be activated. /// - closeCallback: what to do when the middle mouse button is released, and Loop should be closed. init( - openCallback: @escaping (WindowAction?) -> (), + openCallback: @escaping (WindowAction) -> (), closeCallback: @escaping (Bool) -> () ) { // We will never start off with an action from this trigger, so pass in nil @@ -76,11 +76,11 @@ final class MiddleClickTrigger { if event.type == .otherMouseDown, event.getIntegerValueField(.mouseEventButtonNumber) == 2 { if doubleClickToTrigger { - doubleClickTimer.handleTrigger(startingAction: nil) + doubleClickTimer.handleTrigger(startingAction: .init(.noSelection)) } else if useTriggerDelay { - triggerDelayTimer.handleTrigger(startingAction: nil) + triggerDelayTimer.handleTrigger(startingAction: .init(.noSelection)) } else { - openCallback(nil) + openCallback(.init(.noSelection)) } } else { triggerDelayTimer.cancel() diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index 6a47c037..d85b4077 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -23,10 +23,12 @@ final class MouseInteractionObserver { private var previousAngleToMouse: Angle = .zero private var previousDistanceToMouse: CGFloat = .zero - private var radialMenuActions: [RadialMenuWindowAction] { - Defaults[.radialMenuActions] + private var radialMenuActions: [RadialMenuAction] { + RadialMenuAction.userConfiguredActions } + private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) // This helps to keep a stable ID + init( windowActionCache: WindowActionCache, changeAction: @escaping (WindowAction) -> (), @@ -86,7 +88,7 @@ final class MouseInteractionObserver { let initialMousePosition = getInitialMousePosition() let currentMousePosition = NSEvent.mouseLocation - let angleToMouse = Angle(radians: initialMousePosition.angle(to: currentMousePosition)) + let angleToMouse = initialMousePosition.angle(to: currentMousePosition) + .radians(.pi / 2) let distanceToMouse = initialMousePosition.distance(to: currentMousePosition) // Return if the mouse didn't move @@ -101,7 +103,7 @@ final class MouseInteractionObserver { previousAngleToMouse = angleToMouse previousDistanceToMouse = distanceToMouse - var newAction: RadialMenuWindowAction? = nil + var newAction: RadialMenuAction? = nil // If mouse over 50 points away, select half or quarter positions if distanceToMouse > 50 - Defaults[.radialMenuThickness] { @@ -110,23 +112,27 @@ final class MouseInteractionObserver { return } - let actions = Array(radialMenuActions[1...]) + 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 > noActionDistance { - newAction = radialMenuActions.first + newAction = radialMenuActions.last } Task { @MainActor in - switch newAction { + switch newAction?.type { case let .custom(windowAction): changeAction(windowAction) case let .keybindReference(id): - if let action = windowActionCache.actionsByIdentifier[id] { changeAction(action) } + if let action = windowActionCache.actionsByIdentifier[id] { + changeAction(action) + } else { + changeAction(Self.failedToResolveKeybindAction) + } case nil: - changeAction(.init(.noAction)) + changeAction(.init(.noSelection)) } } } diff --git a/Loop/Extensions/CGGeometry+Extensions.swift b/Loop/Extensions/CGGeometry+Extensions.swift index 39e382b7..7d70a346 100644 --- a/Loop/Extensions/CGGeometry+Extensions.swift +++ b/Loop/Extensions/CGGeometry+Extensions.swift @@ -14,12 +14,11 @@ extension CGFloat { } extension CGPoint { - func angle(to comparisonPoint: CGPoint) -> CGFloat { + func angle(to comparisonPoint: CGPoint) -> Angle { let originX = comparisonPoint.x - x let originY = comparisonPoint.y - y let bearingRadians = -atan2f(Float(originY), Float(originX)) - - return CGFloat(bearingRadians) + return .radians(Double(bearingRadians)) } func distance(to comparisonPoint: CGPoint) -> CGFloat { diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 5bff42a8..e9a48a9d 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -77,6 +77,7 @@ extension Defaults.Keys { static let ignoreFullscreen = Key("ignoreFullscreen", default: false, iCloud: true) static let hideUntilDirectionIsChosen = Key("hideUntilDirectionIsChosen", default: false, iCloud: true) static let hapticFeedback = Defaults.Key("hapticFeedback", default: true, iCloud: true) + static let enableRadialMenuCustomization = Defaults.Key("enableRadialMenuCustomization", default: true, iCloud: true) // About static let includeDevelopmentVersions = Key("includeDevelopmentVersions", default: false, iCloud: true) @@ -116,18 +117,17 @@ extension Defaults.Keys { static let previewStartingPosition = Key("previewStartingPosition", default: .screenCenter, iCloud: true) // Radial Menu - static let radialMenuActions = Key<[RadialMenuWindowAction]>( - "radialMenuActions", - default: RadialMenuWindowAction.defaultRadialMenuActions - ) + static let radialMenuActions = Key<[RadialMenuAction]>("radialMenuActions", default: RadialMenuAction.defaultRadialMenuActions) // Migrator static let lastMigratorURL = Key("lastMigratorURL", default: nil) // StashManager - static let stashManagerRevealedWindows = Key>("stashManagerRevealed", default: Set()) 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) @@ -139,5 +139,5 @@ extension Defaults.Keys { static let processWallpaper = Key("processWallpaper", default: false, iCloud: true) // DataPatcher - static let patchesApplied = Key("patchesApplied", default: [], iCloud: true) + static let patchesApplied = Key("patchesApplied", default: [], iCloud: true) } diff --git a/Loop/Extensions/View+Extensions.swift b/Loop/Extensions/View+Extensions.swift new file mode 100644 index 00000000..48a12a11 --- /dev/null +++ b/Loop/Extensions/View+Extensions.swift @@ -0,0 +1,52 @@ +// +// View+Extensions.swift +// Loop +// +// Created by Kai Azim on 2023-06-14. +// + +import SwiftUI + +extension View { + @inlinable + @ViewBuilder + func onChange( + of value: some Equatable, + initial: Bool, + action: @escaping () -> () + ) -> some View { + if initial { + onChange(of: value) { _ in + action() + } + .onAppear { + action() + } + } else { + onChange(of: value) { _ in + action() + } + } + } + + @inlinable + @ViewBuilder + func onChange( + of value: V, + initial: Bool, + action: @escaping (V) -> () + ) -> some View where V: Equatable { + if initial { + onChange(of: value) { newValue in + action(newValue) + } + .onAppear { + action(value) + } + } else { + onChange(of: value) { newValue in + action(newValue) + } + } + } +} diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index c3bf6f55..09f13499 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -659,6 +659,9 @@ } } }, + "Actions" : { + "comment" : "Section header shown in settings" + }, "Add" : { "comment" : "Used to add items to a list", "localizations" : { @@ -742,6 +745,10 @@ } } }, + "Allow radial menu customization" : { + "comment" : "A toggle that allows users to enable or disable the ability to customize the actions available in the radial menu.", + "isCommentAutoGenerated" : true + }, "Almost Maximize" : { "comment" : "Window action", "localizations" : { @@ -2309,6 +2316,10 @@ } } }, + "Cancel" : { + "comment" : "The \"Cancel\" and \"Reset\" buttons in the reset radial menu actions alert.", + "isCommentAutoGenerated" : true + }, "Center" : { "comment" : "Window action", "localizations" : { @@ -2806,6 +2817,7 @@ } }, "Configure padding…" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3549,6 +3561,10 @@ } } }, + "Custom Action" : { + "comment" : "Label for the text field that allows the user to set a custom action name.", + "isCommentAutoGenerated" : true + }, "Custom Cycle" : { "localizations" : { "ar" : { @@ -3632,6 +3648,7 @@ } }, "Custom Keybind" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3713,6 +3730,10 @@ } } }, + "Customize this action's custom frame." : { + "comment" : "A help text describing the customization option for a custom-direction action.", + "isCommentAutoGenerated" : true + }, "Customize this keybind's action." : { "localizations" : { "ar" : { @@ -3796,6 +3817,7 @@ } }, "Customize this keybind's custom frame." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3877,7 +3899,12 @@ } } }, + "Customize what this action cycles through." : { + "comment" : "A description for a button that appears when configuring a cycling window action.", + "isCommentAutoGenerated" : true + }, "Customize what this keybind cycles through." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -5360,6 +5387,10 @@ } } }, + "Failed to resolve linked keybind" : { + "comment" : "An error message displayed when a linked keybind cannot be resolved.", + "isCommentAutoGenerated" : true + }, "First Fourth" : { "comment" : "Window action", "localizations" : { @@ -11220,6 +11251,9 @@ } } }, + "Left-click to step through cycle actions." : { + "comment" : "Section footer shown in settings" + }, "Locked icon alert title" : { "localizations" : { "ar" : { @@ -15585,6 +15619,10 @@ } } }, + "No radial menu actions" : { + "comment" : "A message shown when there are no actions configured for the radial menu.", + "isCommentAutoGenerated" : true + }, "No updates available message 01" : { "localizations" : { "ar" : { @@ -20426,6 +20464,10 @@ } } }, + "Press \"Add\" to add an action" : { + "comment" : "A description displayed below the list of radial menu actions, encouraging the user to add more.", + "isCommentAutoGenerated" : true + }, "Press \"Add\" to add an application" : { "localizations" : { "ar" : { @@ -20923,6 +20965,7 @@ } }, "Quit" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -21169,6 +21212,9 @@ } } }, + "Radial Menu" : { + "comment" : "Section header shown in settings" + }, "Relaxed" : { "comment" : "Animation speed setting", "localizations" : { @@ -21581,6 +21627,17 @@ } } } + }, + "Reset keybinds?" : { + "comment" : "An alert title that asks the user if they want to reset all keybinds.", + "isCommentAutoGenerated" : true + }, + "Reset radial menu actions" : { + "comment" : "A button label that resets the radial menu actions to their default configuration.", + "isCommentAutoGenerated" : true + }, + "Reset radial menu actions?" : { + }, "Resize window under cursor" : { "localizations" : { @@ -26694,6 +26751,18 @@ } } }, + "This action is linked to a keybind. Changes made to this action will affect both." : { + "comment" : "A tooltip explaining that changes to a radial menu action will also affect the corresponding keybind.", + "isCommentAutoGenerated" : true + }, + "This will reset all keybinds to their original defaults." : { + "comment" : "A message displayed in an alert when the user confirms resetting all keybinds.", + "isCommentAutoGenerated" : true + }, + "This will reset all radial menu actions to their default configuration." : { + "comment" : "An alert message explaining that resetting the radial menu actions will reset them to their default configuration.", + "isCommentAutoGenerated" : true + }, "To save power, window animations are\nunavailable in Low Power Mode." : { "localizations" : { "ar" : { @@ -30000,5 +30069,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Loop/Settings Window/Loop/AdvancedConfiguration.swift b/Loop/Settings Window/Loop/AdvancedConfiguration.swift index f7ad1f30..f4cf90ab 100644 --- a/Loop/Settings Window/Loop/AdvancedConfiguration.swift +++ b/Loop/Settings Window/Loop/AdvancedConfiguration.swift @@ -11,10 +11,12 @@ import Luminare import Scribe import SwiftUI +@MainActor final class AdvancedConfigurationModel: ObservableObject { - @Published private(set) var didImportSuccessfullyAlert = false - @Published private(set) var didExportSuccessfullyAlert = false - @Published private(set) var didResetSuccessfullyAlert = false + @Published private(set) var showResetRadialMenuActionsSuccessIndicator = false + @Published private(set) var showImportKeybindsSuccessIndicator = false + @Published private(set) var showExportKeybindsSuccessIndicator = false + @Published private(set) var showResetKeybindsSuccessIndicator = false @Published private(set) var isLowPowerModeEnabled: Bool = ProcessInfo.processInfo.isLowPowerModeEnabled @Published private(set) var isAccessibilityAccessGranted = AccessibilityManager.shared.isGranted @@ -66,7 +68,9 @@ final class AdvancedConfigurationModel: ObservableObject { func importPrompt() { Task { do { - try await Migrator.importPrompt(onSuccess: importedSuccessfully) + try await Migrator.importPrompt { + showSuccessIndicator(\.showImportKeybindsSuccessIndicator) + } } catch { Log.error("Error importing keybinds: \(error)", category: .advancedConfigurationModel) } @@ -77,7 +81,9 @@ final class AdvancedConfigurationModel: ObservableObject { func exportPrompt() { Task { do { - try await Migrator.exportPrompt(onSuccess: exportedSuccessfully) + try await Migrator.exportPrompt { + showSuccessIndicator(\.showExportKeybindsSuccessIndicator) + } } catch { Log.error("Error exporting keybinds: \(error)", category: .advancedConfigurationModel) } @@ -85,56 +91,33 @@ final class AdvancedConfigurationModel: ObservableObject { } /// Resets keybinds to default values. - func reset() { + func resetKeybinds() { Defaults.reset(.keybinds) - resetSuccessfully() + showSuccessIndicator(\.showResetKeybindsSuccessIndicator) } - private func importedSuccessfully() { - DispatchQueue.main.async { [weak self] in - withAnimation(.smooth(duration: 0.5)) { - self?.didImportSuccessfullyAlert = true - } - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - withAnimation(.smooth(duration: 0.5)) { - self?.didImportSuccessfullyAlert = false - } - } + func resetRadialMenuActions() { + Defaults.reset(.radialMenuActions) + showSuccessIndicator(\.showResetRadialMenuActionsSuccessIndicator) } - private func exportedSuccessfully() { - DispatchQueue.main.async { [weak self] in - withAnimation(.smooth(duration: 0.5)) { - self?.didExportSuccessfullyAlert = true - } - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + private func showSuccessIndicator(_ keyPath: ReferenceWritableKeyPath) { + Task { withAnimation(.smooth(duration: 0.5)) { - self?.didExportSuccessfullyAlert = false + self[keyPath: keyPath] = true } - } - } - private func resetSuccessfully() { - DispatchQueue.main.async { [weak self] in - withAnimation(.smooth(duration: 0.5)) { - self?.didResetSuccessfullyAlert = true - } - } + try? await Task.sleep(for: .seconds(2)) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in withAnimation(.smooth(duration: 0.5)) { - self?.didResetSuccessfullyAlert = false + self[keyPath: keyPath] = false } } } } struct AdvancedConfigurationView: View { - @Environment(\.luminareTintColor) var tint + @EnvironmentObject private var windowModel: SettingsWindowManager @Environment(\.luminareAnimation) var luminareAnimation @Environment(\.openURL) private var openURL @@ -148,21 +131,29 @@ struct AdvancedConfigurationView: View { @Default(.ignoreFullscreen) var ignoreFullscreen @Default(.hapticFeedback) var hapticFeedback @Default(.sizeIncrement) var sizeIncrement + @Default(.enableRadialMenuCustomization) var enableRadialMenuCustomization + + @State private var isConfirmingResetKeybinds: Bool = false + @State private var isConfirmingResetRadialMenuActions: Bool = false private var showLowPowerModeWarning: Bool { animateWindowResizes && !ignoreLowPowerMode && model.isLowPowerModeEnabled } var body: some View { - generalSection - keybindsSection - permissionsSection - .onAppear(perform: model.startTracking) - .onDisappear(perform: model.stopTracking) + Group { + generalSection + radialMenuSection + keybindsSection + permissionsSection + .onAppear(perform: model.startTracking) + .onDisappear(perform: model.stopTracking) + } + .animation(luminareAnimation, value: enableRadialMenuCustomization) } private var generalSection: some View { - LuminareSection(String(localized: "General", comment: "Section header shown in settings")) { + LuminareSection { if #available(macOS 15.0, *) { LuminareToggle("Use macOS window manager when available", isOn: $useSystemWindowManagerWhenAvailable) } @@ -194,7 +185,6 @@ struct AdvancedConfigurationView: View { LuminareToggle("Disable cursor interaction", isOn: $disableCursorInteraction) LuminareToggle("Ignore fullscreen windows", isOn: $ignoreFullscreen) - LuminareToggle("Hide until direction is chosen", isOn: $hideUntilDirectionIsChosen) LuminareToggle("Haptic feedback", isOn: $hapticFeedback) LuminareSlider( @@ -209,6 +199,51 @@ 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(isOn: $enableRadialMenuCustomization) { + HStack { + Text("Allow radial menu customization") + + if enableRadialMenuCustomization { + Button { + windowModel.currentTab = .radialMenu + } label: { + Image(systemName: "arrow.up.right.square.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + + if enableRadialMenuCustomization { + Button(role: .destructive) { + isConfirmingResetRadialMenuActions = true + } label: { + HStack { + Text("Reset radial menu actions") + + if model.showResetRadialMenuActionsSuccessIndicator { + Image(systemName: "checkmark") + .foregroundStyle(.green) + .bold() + } + } + } + .luminareRoundingBehavior(bottom: true) + .alert("Reset radial menu actions?", isPresented: $isConfirmingResetRadialMenuActions) { + Button("Cancel", role: .cancel) {} + Button("Reset", role: .destructive, action: model.resetRadialMenuActions) + } message: { + Text("This will reset all radial menu actions to their default configuration.") + } + } + } + } + private var keybindsSection: some View { LuminareSection(String(localized: "Keybinds", comment: "Section header shown in settings")) { HStack(spacing: 4) { @@ -216,9 +251,9 @@ struct AdvancedConfigurationView: View { HStack { Text("Import") - if model.didImportSuccessfullyAlert { + if model.showImportKeybindsSuccessIndicator { Image(systemName: "checkmark") - .foregroundStyle(tint) + .foregroundStyle(.green) .bold() } } @@ -229,26 +264,34 @@ struct AdvancedConfigurationView: View { HStack { Text("Export") - if model.didExportSuccessfullyAlert { + if model.showExportKeybindsSuccessIndicator { Image(systemName: "checkmark") - .foregroundStyle(tint) + .foregroundStyle(.green) .bold() } } } - Button(role: .destructive, action: model.reset) { + Button(role: .destructive) { + isConfirmingResetKeybinds = true + } label: { HStack { Text("Reset") - if model.didResetSuccessfullyAlert { + if model.showResetKeybindsSuccessIndicator { Image(systemName: "checkmark") - .foregroundStyle(tint) + .foregroundStyle(.green) .bold() } } } .luminareRoundingBehavior(trailing: true) + .alert("Reset keybinds?", isPresented: $isConfirmingResetKeybinds) { + Button("Cancel", role: .cancel) {} + Button("Reset", role: .destructive, action: model.resetKeybinds) + } message: { + Text("This will reset all keybinds to their original defaults.") + } } } } @@ -265,7 +308,7 @@ struct AdvancedConfigurationView: View { HStack { if model.isAccessibilityAccessGranted { Image(systemName: "checkmark.seal.fill") - .foregroundStyle(tint) + .foregroundStyle(.green) } Text("Accessibility access") diff --git a/Loop/Settings Window/Settings/Keybinds/Item View/DirectionPickerView.swift b/Loop/Settings Window/Settings/Keybinds/DirectionPickerView.swift similarity index 54% rename from Loop/Settings Window/Settings/Keybinds/Item View/DirectionPickerView.swift rename to Loop/Settings Window/Settings/Keybinds/DirectionPickerView.swift index a55b4d11..d2c65d37 100644 --- a/Loop/Settings Window/Settings/Keybinds/Item View/DirectionPickerView.swift +++ b/Loop/Settings Window/Settings/Keybinds/DirectionPickerView.swift @@ -16,22 +16,9 @@ struct DirectionPickerView: View { @Binding private var direction: WindowDirection private let isInCycle: Bool - private static let sections: [PickerSection] = [ - .init(String(localized: "General", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.general), - .init(String(localized: "Halves", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.halves), - .init(String(localized: "Quarters", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.quarters), - .init(String(localized: "Horizontal Thirds", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.horizontalThirds), - .init(String(localized: "Vertical Thirds", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.verticalThirds), - .init(String(localized: "Horizontal Fourths", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.horizontalFourths), - .init(String(localized: "Screen Switching", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.screenSwitching), - .init(String(localized: "Size Adjustment", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.sizeAdjustment), - .init(String(localized: "Shrink", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.shrink), - .init(String(localized: "Grow", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.grow), - .init(String(localized: "Move", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.move), - .init(String(localized: "Focus", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.focus), - .init(String(localized: "Stash", comment: "Section header in the action picker of the Keybinds tab"), [WindowDirection.stash, WindowDirection.unstash]), - .init(String(localized: "Go Back", comment: "Section header in the action picker of the Keybinds tab"), [WindowDirection.initialFrame, WindowDirection.undo]) - ] + private var sections: [PickerSection] { + PickerSection.windowDirections + } private var moreSection: PickerSection { let title = String(localized: "More", comment: "Section header in the action picker of the Keybinds tab") @@ -43,13 +30,9 @@ struct DirectionPickerView: View { } private var sectionItems: [WindowDirection] { - var result: [WindowDirection] = [] - - for sectionItems in Self.sections.map(\.items) { - result.append(contentsOf: sectionItems) - } - - return result + sections + .map(\.items) + .flatMap(\.self) } init(direction: Binding, isInCycle: Bool) { @@ -59,8 +42,11 @@ struct DirectionPickerView: View { var body: some View { VStack(spacing: 0) { - CustomTextField($searchText) - .padding(padding) + CustomTextField( + $searchText, + placeholder: .init(localized: "Search for a window action", defaultValue: "Search…") + ) + .padding(padding) Divider() @@ -68,7 +54,7 @@ struct DirectionPickerView: View { $direction, $searchResults, padding, - Self.sections + [moreSection] + sections + [moreSection] ) { item in HStack(spacing: 8) { IconView(action: .init(item)) diff --git a/Loop/Settings Window/Settings/Keybinds/Item View/KeybindItemView.swift b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift similarity index 93% rename from Loop/Settings Window/Settings/Keybinds/Item View/KeybindItemView.swift rename to Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift index 17ceab3a..27b3bf53 100644 --- a/Loop/Settings Window/Settings/Keybinds/Item View/KeybindItemView.swift +++ b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift @@ -64,11 +64,11 @@ struct KeybindItemView: View { Group { if action.direction.isCustomizable { - Button(action: { + Button { isConfiguringCustom = true - }, label: { + } label: { Image(systemName: "slider.horizontal.3") - }) + } .buttonStyle(.plain) .luminareModalWithPredefinedSheetStyle(isPresented: $isConfiguringCustom, isCompact: false) { if action.direction == .custom { @@ -79,21 +79,21 @@ struct KeybindItemView: View { .frame(width: 400) } } - .help("Customize this keybind's custom frame.") + .help("Customize this action's custom frame.") } if action.direction == .cycle { - Button(action: { + Button { isConfiguringCycle = true - }, label: { + } label: { Image(systemName: "repeat") - }) + } .buttonStyle(.plain) .luminareModalWithPredefinedSheetStyle(isPresented: $isConfiguringCycle, isCompact: false) { CycleActionConfigurationView(action: $action, isPresented: $isConfiguringCycle) .frame(width: 400) } - .help("Customize what this keybind cycles through.") + .help("Customize what this action cycles through.") } } .font(.title3) @@ -164,10 +164,7 @@ struct KeybindItemView: View { } .padding(.horizontal, 4) } - .luminareContentSize( - contentMode: .fit, - hasFixedHeight: true - ) + .luminareContentSize(contentMode: .fit, hasFixedHeight: true) .luminareRoundingBehavior(top: true, bottom: true) .luminareFilledStates([.hovering, .pressed]) .luminareBorderedStates(.hovering) diff --git a/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift index c454693c..f4fa7096 100644 --- a/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift @@ -16,7 +16,7 @@ final class KeybindsConfigurationModel: ObservableObject { struct KeybindsConfigurationView: View { @Environment(\.luminareAnimation) private var luminareAnimation - + @EnvironmentObject private var windowModel: SettingsWindowManager @StateObject private var model = KeybindsConfigurationModel() @Default(.triggerKey) private var triggerKey @@ -163,6 +163,17 @@ struct KeybindsConfigurationView: View { .padding() } .luminareRoundingBehavior(bottom: true) + .onChange(of: model.selectedKeybinds, initial: true) { + if model.selectedKeybinds.count == 1, let action = model.selectedKeybinds.first { + windowModel.isPreviewingUserSelection = true + windowModel.setPreviewedAction(to: action) + } else { + windowModel.isPreviewingUserSelection = false + } + } + .onDisappear { + windowModel.isPreviewingUserSelection = false + } } } } diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift index 686d4dca..0cbe2e9d 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/CustomActionConfigurationView.swift @@ -39,6 +39,10 @@ struct CustomActionConfigurationView: View { private let previewController = PreviewController() private let screenSize: CGSize = NSScreen.main?.frame.size ?? NSScreen.screens[0].frame.size + private var showMacOSCenterToggle: Bool { + action.anchor ?? .center == .center || action.anchor == .macOSCenter + } + init(action: Binding, isPresented: Binding) { _windowAction = action _isPresented = isPresented @@ -77,7 +81,7 @@ struct CustomActionConfigurationView: View { private func configurationSections() -> some View { LuminareSection(outerPadding: 0) { LuminareTextField( - "Custom Keybind", + "Custom Action", text: Binding( get: { action.name ?? "" }, set: { action.name = $0 } @@ -221,9 +225,9 @@ struct CustomActionConfigurationView: View { ) { anchor in IconView(action: anchor.iconAction) } - .luminareRoundingBehavior(bottom: true) + .luminareRoundingBehavior(bottom: !showMacOSCenterToggle) - if action.anchor ?? .center == .center || action.anchor == .macOSCenter { + if showMacOSCenterToggle { LuminareToggle( isOn: Binding( get: { diff --git a/Loop/Settings Window/Settings/Keybinds/Modal Views/CycleActionConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/Modal Views/CycleActionConfigurationView.swift index 100a3fc1..1077698c 100644 --- a/Loop/Settings Window/Settings/Keybinds/Modal Views/CycleActionConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/Modal Views/CycleActionConfigurationView.swift @@ -52,11 +52,7 @@ struct CycleActionConfigurationView: View { LuminareList( items: Binding( get: { - if action.cycle == nil { - action.cycle = [] - } - - return action.cycle ?? [] + action.cycle ?? [] }, set: { newValue in action.cycle = newValue } @@ -95,5 +91,10 @@ struct CycleActionConfigurationView: View { } .luminareCornerRadius(8) } + .onAppear { + if action.cycle == nil { + action.cycle = [] + } + } } } diff --git a/Loop/Settings Window/SettingsContentView.swift b/Loop/Settings Window/SettingsContentView.swift index 4917569c..972119bf 100644 --- a/Loop/Settings Window/SettingsContentView.swift +++ b/Loop/Settings Window/SettingsContentView.swift @@ -5,6 +5,7 @@ // Created by Kai Azim on 2025-10-18. // +import Defaults import Luminare import SwiftUI @@ -14,6 +15,7 @@ struct SettingsContentView: View { @Environment(\.luminareAnimation) private var animation @Environment(\.luminareTitleBarHeight) private var titleBarHeight + @Default(.enableRadialMenuCustomization) var enableRadialMenuCustomization var body: some View { LuminareDividedStack { @@ -51,18 +53,21 @@ struct SettingsContentView: View { if model.showInspector { ZStack { - if model.showPreview { - LuminarePreviewView() - } + LuminarePreviewView() + .allowsHitTesting(false) if model.showRadialMenu { - VStack { - RadialMenuView(viewModel: model.radialMenuViewModel) + RadialMenuView(viewModel: model.radialMenuViewModel) + .allowsHitTesting(false) + + if enableRadialMenuCustomization, model.currentTab == .radialMenu { + RadialMenuActionsGuide() } - .frame(maxHeight: .infinity, alignment: .center) } } + .compositingGroup() .animation(animation, value: [model.showRadialMenu, model.showPreview]) + .padding(12) .frame(width: 520) } } @@ -74,5 +79,6 @@ struct SettingsContentView: View { } .luminareTint(overridingWith: accentColorController.color1) .ignoresSafeArea() + .environmentObject(model) } } diff --git a/Loop/Settings Window/SettingsWindowManager.swift b/Loop/Settings Window/SettingsWindowManager.swift index c8fa2484..8e7ffa23 100644 --- a/Loop/Settings Window/SettingsWindowManager.swift +++ b/Loop/Settings Window/SettingsWindowManager.swift @@ -17,7 +17,14 @@ final class SettingsWindowManager: ObservableObject { private var controller: NSWindowController? private var previewActionTimerTask: Task<(), Error>? - @Published private(set) var previewedAction: WindowAction + @Published var isPreviewingUserSelection: Bool = false { + didSet { restartTimer() } + } + + @Published private(set) var previewedParentAction: WindowAction? = nil + @Published private(set) var previewedAction: WindowAction = .init(.noSelection) { + didSet { radialMenuViewModel.setAction(to: previewedAction, parent: previewedParentAction) } + } @Published var showRadialMenu: Bool = false @Published var showPreview: Bool = false @@ -54,10 +61,13 @@ final class SettingsWindowManager: ObservableObject { } private init() { - let startingAction: WindowAction = .init(.topHalf) + let startingAction: WindowAction = .init(.noAction) - self.previewedAction = startingAction self.radialMenuViewModel = .init(startingAction: startingAction, window: nil, previewMode: true) + + if let firstAction = RadialMenuAction.userConfiguredActions.first?.resolved { + setPreviewedAction(to: firstAction) + } } func show() { @@ -108,18 +118,22 @@ final class SettingsWindowManager: ObservableObject { Log.success("Settings window closed", category: .settingsWindowManager) } + private func restartTimer() { + stopTimer() + startTimer() + } + private func startTimer() { previewActionTimerTask?.cancel() previewActionTimerTask = Task(priority: .utility) { - while true { - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .seconds(1)) - if controller?.window?.isKeyWindow == true, !Task.isCancelled { - await MainActor.run { - previewedAction.direction = previewedAction.direction.nextPreviewDirection - radialMenuViewModel.setAction(to: previewedAction) - } + while !Task.isCancelled { + if controller?.window?.isKeyWindow == true { + setNextPreviewedAction() } + + try await Task.sleep(for: .seconds(1)) } } } @@ -128,4 +142,40 @@ final class SettingsWindowManager: ObservableObject { previewActionTimerTask?.cancel() previewActionTimerTask = nil } + + private func setNextPreviewedAction() { + if isPreviewingUserSelection { + guard let parent = previewedParentAction, + parent.direction == .cycle, + let cycle = parent.cycle, + let index = cycle.firstIndex(of: previewedAction) + else { + return + } + + let nextIndex = (index + 1) % cycle.count + setPreviewedAction(to: parent, cycleAction: cycle[nextIndex]) + } else { + let radialMenuActions: [WindowAction] = RadialMenuAction.userConfiguredActions + .compactMap(\.resolved) + + let nextAction = if let index = radialMenuActions.firstIndex(of: previewedParentAction ?? previewedAction) { + radialMenuActions[(index + 1) % radialMenuActions.count] + } else { + radialMenuActions.first ?? .init(.noAction) + } + + setPreviewedAction(to: nextAction) + } + } + + func setPreviewedAction(to newAction: WindowAction, cycleAction: WindowAction? = nil) { + if newAction.direction == .cycle { + previewedParentAction = newAction + previewedAction = cycleAction ?? newAction.cycle?.first ?? .init(.noAction) + } else { + previewedParentAction = nil + previewedAction = newAction + } + } } diff --git a/Loop/Settings Window/Theming/AccentColorConfiguration.swift b/Loop/Settings Window/Theming/AccentColorConfiguration.swift index fc485efc..5fef1654 100644 --- a/Loop/Settings Window/Theming/AccentColorConfiguration.swift +++ b/Loop/Settings Window/Theming/AccentColorConfiguration.swift @@ -12,7 +12,6 @@ import SwiftUI // MARK: - View struct AccentColorConfigurationView: View { - @Environment(\.luminareTintColor) var tint @Environment(\.luminareAnimation) private var luminareAnimation @ObservedObject private var accentColorController: AccentColorController = .shared @@ -53,7 +52,7 @@ struct AccentColorConfigurationView: View { if didSyncWallpaper { Image(systemName: "checkmark") - .foregroundStyle(tint) + .foregroundStyle(.green) .bold() } } diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift new file mode 100644 index 00000000..7a416e3c --- /dev/null +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -0,0 +1,228 @@ +// +// RadialMenuActionItemView.swift +// Loop +// +// Created by Kai Azim on 2025-12-08. +// + +import Defaults +import Luminare +import SwiftUI + +@MainActor +final class RadialMenuWindowActionWrapper: ObservableObject { + @Published var isConfiguringCustom: Bool = false + @Published var isConfiguringCycle: Bool = false + @Published var action: RadialMenuAction { + didSet { updateBindingAction() } + } + + private let bindingAction: Binding + + init(binding action: Binding) { + self.action = action.wrappedValue + self.bindingAction = action + } + + private func updateBindingAction() { + guard bindingAction.wrappedValue != action else { return } + bindingAction.wrappedValue = action + + guard let resolvedAction = action.resolved else { + isConfiguringCustom = false + isConfiguringCycle = false + return + } + + Task { + isConfiguringCustom = resolvedAction.direction.isCustomizable + isConfiguringCycle = resolvedAction.direction == .cycle + } + } +} + +struct RadialMenuActionItemView: View { + @EnvironmentObject private var windowModel: SettingsWindowManager + @Environment(\.luminareItemBeingHovered) private var isHovering + @Environment(\.luminareAnimation) var luminareAnimation + @StateObject private var wrapper: RadialMenuWindowActionWrapper + + @Default(.radialMenuActions) private var radialMenuActions + @Default(.keybinds) private var keybinds + + private let moveUp: () -> () + private let moveDown: () -> () + + @State private var isPickerPresented = false + + init( + _ action: Binding, + moveUp: @escaping () -> (), + moveDown: @escaping () -> () + ) { + self._wrapper = StateObject(wrappedValue: RadialMenuWindowActionWrapper(binding: action)) + self.moveUp = moveUp + self.moveDown = moveDown + } + + var body: some View { + HStack(spacing: 12) { + label + + Spacer() + + if wrapper.action.type.isKeybindReference { + Image(systemName: "keyboard") + .foregroundStyle(.secondary) + .help("This action is linked to a keybind. Changes made to this action will affect both.") + } + + HStack(spacing: 8) { + Button(action: moveUp) { + Image(systemName: "arrow.up") + .frame(width: 27, height: 27) + .font(.callout) + .contentShape(.rect) + } + .luminareContentSize(aspectRatio: 1.0, contentMode: .fit, hasFixedHeight: true) + .luminareRoundingBehavior(top: true, bottom: true) + + Button(action: moveDown) { + Image(systemName: "arrow.down") + .frame(width: 27, height: 27) + .font(.callout) + .contentShape(.rect) + } + .luminareContentSize(aspectRatio: 1.0, contentMode: .fit, hasFixedHeight: true) + .luminareRoundingBehavior(top: true, bottom: true) + } + } + .padding(.horizontal, 12) + .onChange(of: isHovering) { _ in + if !isHovering { + isPickerPresented = false + } + } + } + + @ViewBuilder + private var label: some View { + actionIndicator + .background { + if isHovering { + Color.clear + .luminarePopup( + isPresented: $isPickerPresented, + alignment: .leadingLastTextBaseline + ) { + RadialMenuActionPickerView(selection: $wrapper.action.type) + } + .luminareSheetClosesOnDefocus(true) + } + } + } + + @ViewBuilder + var actionIndicator: some View { + HStack(spacing: 2) { + Button { + isPickerPresented = true + } label: { + HStack(spacing: 8) { + if let action = wrapper.action.resolved { + IconView(action: action) + + Text(action.getName()) + .fontWeight(.regular) + .lineLimit(1) + } else { + Image(systemName: "bolt.horizontal.fill") + .foregroundStyle(.secondary) + + Text("Failed to resolve linked keybind") + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 4) + } + .luminareContentSize(contentMode: .fit, hasFixedHeight: true) + .luminareRoundingBehavior(top: true, bottom: true) + .luminareFilledStates([.hovering, .pressed]) + .luminareBorderedStates(.hovering) + .luminareMinHeight(24) + .padding(.leading, -4) + + Group { + if let resolvedAction = wrapper.action.resolved { + let actionBinding = Binding( + get: { + resolvedAction + }, + set: { newAction in + switch wrapper.action.type { + case .custom: + wrapper.action.type = .custom(newAction) + case .keybindReference: + guard let index = Defaults[.keybinds].firstIndex(where: { $0.id == wrapper.action.associatedActionId }) else { + return + } + + keybinds[index] = newAction + } + } + ) + + if resolvedAction.direction.isCustomizable { + Button { + wrapper.isConfiguringCustom = true + } label: { + Image(systemName: "slider.horizontal.3") + } + .buttonStyle(.plain) + .luminareModalWithPredefinedSheetStyle( + isPresented: $wrapper.isConfiguringCustom, + isCompact: false + ) { + if resolvedAction.direction == .custom { + CustomActionConfigurationView( + action: actionBinding, + isPresented: $wrapper.isConfiguringCustom + ) + .frame(width: 400) + } else { + StashActionConfigurationView( + action: actionBinding, + isPresented: $wrapper.isConfiguringCustom + ) + .frame(width: 400) + } + } + .help("Customize this action's custom frame.") + } + + if resolvedAction.direction == .cycle { + Button { + wrapper.isConfiguringCycle = true + } label: { + Image(systemName: "repeat") + } + .buttonStyle(.plain) + .luminareModalWithPredefinedSheetStyle( + isPresented: $wrapper.isConfiguringCycle, + isCompact: false + ) { + CycleActionConfigurationView( + action: actionBinding, + isPresented: $wrapper.isConfiguringCycle + ) + .frame(width: 400) + } + .help("Customize what this action cycles through.") + } + } + } + .font(.title3) + .foregroundStyle(isHovering ? .primary : .secondary) + } + } +} diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift new file mode 100644 index 00000000..72a42f84 --- /dev/null +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift @@ -0,0 +1,158 @@ +// +// RadialMenuActionPickerView.swift +// Loop +// +// Created by Kai Azim on 2026-01-02. +// + +import Defaults +import SwiftUI + +struct RadialMenuActionPickerView: View { + @Default(.keybinds) private var keybinds + + private let padding: CGFloat = 12 + + @State private var searchText = "" + @State private var searchResults: [RadialMenuAction.ActionType] = [] + + @Binding private var selection: RadialMenuAction.ActionType + + private static let directionSections: [PickerSection] = { + let windowDirections = PickerSection.windowDirections + .map { section in + PickerSection( + section.title, + section.items.map { RadialMenuAction.ActionType.custom(.init($0)) } + ) + } + + let moreSection = PickerSection( + String(localized: "More", comment: "Section header in the action picker of the Keybinds tab"), + [WindowDirection.custom, WindowDirection.cycle].map { RadialMenuAction.ActionType.custom(.init($0)) } + ) + + return windowDirections + [moreSection] + }() + + private var keybindsSection: PickerSection { + PickerSection( + "Your Keybinds", + keybinds.map { RadialMenuAction.ActionType.keybindReference($0.id) } + ) + } + + private var allSections: [PickerSection] { + Self.directionSections + [keybindsSection] + } + + private var allSectionItems: [RadialMenuAction.ActionType] { + allSections + .map(\.items) + .flatMap(\.self) + } + + init(selection: Binding) { + self._selection = selection + } + + var body: some View { + VStack(spacing: 0) { + CustomTextField( + $searchText, + placeholder: .init(localized: "Search for a window action", defaultValue: "Search…") + ) + .padding(padding) + + Divider() + + PickerList( + $selection, + $searchResults, + padding, + allSections + ) { item in + HStack(spacing: 8) { + if let action = item.resolvedAction { + HStack(spacing: 8) { + IconView(action: action) + + Text(action.getName()) + .fontWeight(.regular) + .lineLimit(1) + } + } else { + Image(systemName: "bolt.horizontal.fill") + } + + Spacer() + + if item.isKeybindReference { + Image(systemName: "keyboard") + .foregroundStyle(.secondary) + } + } + } + } + .frame(width: 300, height: 300) + .onAppear { + searchText = "" + computeSearchResults() + } + .onDisappear { + searchText = "" + } + .onChange(of: searchText) { _ in + computeSearchResults() + } + } + + private func computeSearchResults() { + guard !searchText.isEmpty else { + searchResults = [] + return + } + + let key = searchText.lowercased() + + let matches = allSectionItems + .compactMap { item -> (RadialMenuAction.ActionType, Int)? in + guard let action = item.resolvedAction else { return nil } + + if let score = fuzzyScore(action.getName(), key) { + return (item, score) + } + + return nil + } + .sorted { $0.1 < $1.1 } + .map(\.0) + + searchResults = matches + } + + private func fuzzyScore(_ text: String, _ pattern: String) -> Int? { + let text = text.lowercased() + let pattern = pattern.lowercased() + + // Strong prefix match + if text.hasPrefix(pattern) { return 0 } + + // Contains substring + if text.contains(pattern) { return 1 } + + // Subsequence fuzzy match (letters appear in order) + var tIndex = text.startIndex + var pIndex = pattern.startIndex + while tIndex < text.endIndex, pIndex < pattern.endIndex { + if text[tIndex] == pattern[pIndex] { + pIndex = text.index(after: pIndex) + } + tIndex = text.index(after: tIndex) + } + + if pIndex == pattern.endIndex { return 2 } + + return nil + } +} diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift new file mode 100644 index 00000000..835a9f19 --- /dev/null +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift @@ -0,0 +1,125 @@ +// +// RadialMenuActionsGuide.swift +// Loop +// +// Created by Kai Azim on 2026-01-01. +// + +import Defaults +import Luminare +import SwiftUI + +struct RadialMenuActionsGuide: View { + @EnvironmentObject private var windowModel: SettingsWindowManager + @ObservedObject private var accentColorController: AccentColorController = .shared + @Environment(\.luminareAnimation) private var luminareAnimation + + @Default(.radialMenuActions) private var radialMenuActions + + private var radialActions: [RadialMenuAction] { + Array(radialMenuActions.dropLast()) + } + + private var centerAction: RadialMenuAction { + radialMenuActions.last ?? .custom(.init(.noAction)) + } + + private var activeAction: WindowAction { + windowModel.previewedParentAction ?? windowModel.previewedAction + } + + private var selectedColor: Color { + windowModel.isPreviewingUserSelection ? accentColorController.color1.opacity(0.6) : accentColorController.color2.opacity(0.3) + } + + private var buttonShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 12) + } + + var body: some View { + ZStack { + if let centerResolved = centerAction.resolved { + actionButton( + action: centerResolved, + isActive: centerResolved == activeAction + ) { + IconView(action: centerResolved) + } + } else { + actionButton(isActive: false) { + Image(systemName: "bolt.horizontal.fill") + .foregroundStyle(.secondary) + } + } + + RadialLayout { + ForEach(Array(radialMenuActions.dropLast()), id: \.id) { action in + if let resolved = action.resolved { + actionButton( + action: resolved, + isActive: resolved == activeAction + ) { + IconView(action: resolved) + } + } else { + actionButton(isActive: false) { + Image(systemName: "bolt.horizontal.fill") + .foregroundStyle(.secondary) + } + } + } + } + } + .compositingGroup() + .shadow(radius: 8) + .frame(width: 200, height: 200) + .animation(luminareAnimation, value: radialMenuActions) + } + + @ViewBuilder + private func actionButton( + action: WindowAction? = nil, + isActive: Bool, + content: () -> some View + ) -> some View { + Button { + guard let action else { + return + } + + if windowModel.previewedParentAction ?? windowModel.previewedAction == action { + windowModel.isPreviewingUserSelection.toggle() + } else { + windowModel.isPreviewingUserSelection = true + } + + if windowModel.isPreviewingUserSelection { + windowModel.setPreviewedAction(to: action) + } + } label: { + ZStack { + if #available(macOS 26.0, *) { + content() + .frame(width: 30, height: 30) + .glassEffect( + .clear.tint(isActive ? selectedColor : nil), + in: buttonShape + ) + } else { + content() + .frame(width: 30, height: 30) + } + } + .background { + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + .padding(0.5) // Fixes odd clipping behavior where slither of view is shown at top + .clipShape(buttonShape) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .scaleEffect(isActive ? 1.05 : 0.95) + .disabled(action == nil) + .animation(luminareAnimation, value: isActive) + } +} diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift new file mode 100644 index 00000000..a556872c --- /dev/null +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -0,0 +1,147 @@ +// +// RadialMenuConfigurationView.swift +// Loop +// +// Created by Kai Azim on 2024-04-19. +// + +import Defaults +import Luminare +import SwiftUI + +struct RadialMenuConfigurationView: View { + @EnvironmentObject private var windowModel: SettingsWindowManager + + @Default(.radialMenuVisibility) private var radialMenuVisibility + @Default(.radialMenuCornerRadius) private var radialMenuCornerRadius + @Default(.radialMenuThickness) private var radialMenuThickness + @Default(.enableRadialMenuCustomization) var enableRadialMenuCustomization + @Default(.radialMenuActions) private var radialMenuActions + @State private var selectedRadialMenuActions: Set = [] + + var body: some View { + LuminareSection { + LuminareToggle("Radial menu", isOn: $radialMenuVisibility) + + if radialMenuVisibility { + LuminareSlider( + "Corner radius", + value: $radialMenuCornerRadius.doubleBinding, + in: 30...50, + format: .number.precision(.fractionLength(0...0)), + clampsUpper: true, + clampsLower: true, + suffix: Text("px", comment: "Unit symbol: pixels") + ) + .onChange(of: radialMenuCornerRadius) { _ in + if radialMenuCornerRadius - 1 < radialMenuThickness { + radialMenuThickness = radialMenuCornerRadius - 1 + } + } + + LuminareSlider( + "Thickness", + value: $radialMenuThickness.doubleBinding, + in: 10...35, + format: .number.precision(.fractionLength(0...0)), + clampsUpper: true, + clampsLower: true, + suffix: Text("px", comment: "Unit symbol: pixels") + ) + .onChange(of: radialMenuThickness) { _ in + if radialMenuThickness + 1 > radialMenuCornerRadius { + radialMenuCornerRadius = radialMenuThickness + 1 + } + } + } + } + .animation(.smooth(duration: 0.25), value: radialMenuVisibility) + + if enableRadialMenuCustomization { + LuminareSection( + String(localized: "Actions", comment: "Section header shown in settings"), + String(localized: "Left-click to step through cycle actions.", comment: "Section footer shown in settings") + ) { + HStack(spacing: 4) { + Button("Add") { + radialMenuActions.insert(.custom(.init(.noAction)), at: 0) + } + .luminareRoundingBehavior(topLeading: true) + + Button("Remove", role: .destructive) { + radialMenuActions.removeAll(where: selectedRadialMenuActions.contains) + } + .luminareRoundingBehavior(topTrailing: true) + .disabled(selectedRadialMenuActions.isEmpty) + .keyboardShortcut(.delete) + } + + LuminareList( + items: $radialMenuActions, + selection: $selectedRadialMenuActions, + id: \.id + ) { action in + RadialMenuActionItemView( + action, + moveUp: { moveAction(action.wrappedValue, down: false) }, + moveDown: { moveAction(action.wrappedValue, down: true) } + ) + } emptyView: { + HStack { + Spacer() + VStack { + Text("No radial menu actions") + .font(.title3) + Text("Press \"Add\" to add an action") + .font(.caption) + } + Spacer() + } + .foregroundStyle(.secondary) + .padding() + } + .luminareRoundingBehavior(bottom: true) + .onChange(of: selectedRadialMenuActions, perform: userSelectionChanged) + .onChange(of: windowModel.previewedParentAction ?? windowModel.previewedAction, perform: previewedActionChanged) + .onDisappear { + windowModel.isPreviewingUserSelection = false + } + } + } + } + + private func moveAction(_ action: RadialMenuAction, down: Bool) { + guard + let index = radialMenuActions.firstIndex(where: { $0.id == action.id }) + else { return } + + let newIndex = index + (down ? 1 : -1) + guard radialMenuActions.indices.contains(newIndex) else { return } + + radialMenuActions.move( + fromOffsets: IndexSet(integer: index), + toOffset: newIndex > index ? newIndex + 1 : newIndex + ) + } + + private func userSelectionChanged(_ newValue: Set) { + if newValue.count == 1, let resolved = newValue.first?.resolved { + windowModel.isPreviewingUserSelection = true + windowModel.setPreviewedAction(to: resolved) + } else { + windowModel.isPreviewingUserSelection = false + } + } + + private func previewedActionChanged(_ newAction: WindowAction) { + guard windowModel.isPreviewingUserSelection else { + return + } + + if let match = radialMenuActions.first(where: { $0.associatedActionId == newAction.id }) { + selectedRadialMenuActions = [match] + } else { + selectedRadialMenuActions = [] + } + } +} diff --git a/Loop/Settings Window/Theming/RadialMenuConfiguration.swift b/Loop/Settings Window/Theming/RadialMenuConfiguration.swift deleted file mode 100644 index b7017a5b..00000000 --- a/Loop/Settings Window/Theming/RadialMenuConfiguration.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// RadialMenuConfiguration.swift -// Loop -// -// Created by Kai Azim on 2024-04-19. -// - -import Defaults -import Luminare -import SwiftUI - -struct RadialMenuConfigurationView: View { - @Default(.radialMenuVisibility) private var radialMenuVisibility - @Default(.radialMenuCornerRadius) private var radialMenuCornerRadius - @Default(.radialMenuThickness) private var radialMenuThickness - - var body: some View { - LuminareSection { - LuminareToggle("Radial menu", isOn: $radialMenuVisibility) - - if radialMenuVisibility { - LuminareSlider( - "Corner radius", - value: $radialMenuCornerRadius.doubleBinding, - in: 30...50, - format: .number.precision(.fractionLength(0...0)), - clampsUpper: true, - clampsLower: true, - suffix: Text("px", comment: "Unit symbol: pixels") - ) - .onChange(of: radialMenuCornerRadius) { _ in - if radialMenuCornerRadius - 1 < radialMenuThickness { - radialMenuThickness = radialMenuCornerRadius - 1 - } - } - - LuminareSlider( - "Thickness", - value: $radialMenuThickness.doubleBinding, - in: 10...35, - format: .number.precision(.fractionLength(0...0)), - clampsUpper: true, - clampsLower: true, - suffix: Text("px", comment: "Unit symbol: pixels") - ) - .onChange(of: radialMenuThickness) { _ in - if radialMenuThickness + 1 > radialMenuCornerRadius { - radialMenuCornerRadius = radialMenuThickness + 1 - } - } - } - } - .animation(.smooth(duration: 0.25), value: radialMenuVisibility) - } -} diff --git a/Loop/Stashing/StashManager.swift b/Loop/Stashing/StashManager.swift index 0d370d57..6518b390 100644 --- a/Loop/Stashing/StashManager.swift +++ b/Loop/Stashing/StashManager.swift @@ -415,7 +415,7 @@ 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.isSameManipulation(as: windowToStash.action), stashedWindow.screen.isSameScreen(windowToStash.screen) { + 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) unstash(stashedWindow, resetFrame: true, resetFrameAnimated: animate) } else { diff --git a/Loop/Stashing/StashedWindowStore.swift b/Loop/Stashing/StashedWindowStore.swift index 4fc8209b..8e38bbc9 100644 --- a/Loop/Stashing/StashedWindowStore.swift +++ b/Loop/Stashing/StashedWindowStore.swift @@ -23,9 +23,7 @@ final class StashedWindowsStore { didSet { persistStashedWindows() } } - var revealed: Set = [] { - didSet { persistRevealedWindows() } - } + private(set) var revealed: Set = [] /// Hold data from `Defaults[.stashManagerStashedWindows]` for windows that failed to be restored. private var failedToRestore: [CGWindowID: WindowAction] = [:] @@ -34,7 +32,6 @@ final class StashedWindowsStore { // MARK: - Public methods func restore() { - restoreRevealedWindows() restoreStashedWindows() } @@ -53,7 +50,7 @@ final class StashedWindowsStore { /// Return the stashed window that match the given `action` and `screen` func stashedWindow(for action: WindowAction, on screen: NSScreen) -> StashedWindow? { for stashedWindow in stashed.values { - if stashedWindow.action.isSameManipulation(as: action), stashedWindow.screen.isSameScreen(screen) { + if stashedWindow.action.id == action.id, stashedWindow.screen.isSameScreen(screen) { return stashedWindow } } @@ -62,10 +59,6 @@ final class StashedWindowsStore { // MARK: Private methods - func restoreRevealedWindows() { - revealed = Defaults[.stashManagerRevealedWindows] - } - func restoreStashedWindows() { let windows = WindowUtility.windowList() let defaultStashedWindows = Defaults[.stashManagerStashedWindows] @@ -130,10 +123,6 @@ final class StashedWindowsStore { return StashedWindow(window: window, screen: screen, action: action) } - func persistRevealedWindows() { - Defaults[.stashManagerRevealedWindows] = revealed - } - func persistStashedWindows() { Defaults[.stashManagerStashedWindows] = stashed.mapValues(\.action) } diff --git a/Loop/Settings Window/Settings/Keybinds/Item View/CustomTextField.swift b/Loop/Utilities/CustomTextField.swift similarity index 92% rename from Loop/Settings Window/Settings/Keybinds/Item View/CustomTextField.swift rename to Loop/Utilities/CustomTextField.swift index 42a5ddd8..0cae2196 100644 --- a/Loop/Settings Window/Settings/Keybinds/Item View/CustomTextField.swift +++ b/Loop/Utilities/CustomTextField.swift @@ -12,7 +12,7 @@ struct CustomTextField: NSViewRepresentable { @Binding var text: String let placeholder: String - init(_ text: Binding, _ placeholder: String = .init(localized: "Search for a window action", defaultValue: "Search…")) { + init(_ text: Binding, placeholder: String) { self._text = text self.placeholder = placeholder } diff --git a/Loop/Settings Window/Settings/Keybinds/Item View/PickerList.swift b/Loop/Utilities/PickerList.swift similarity index 73% rename from Loop/Settings Window/Settings/Keybinds/Item View/PickerList.swift rename to Loop/Utilities/PickerList.swift index 6fa9eec7..368ab6c7 100644 --- a/Loop/Settings Window/Settings/Keybinds/Item View/PickerList.swift +++ b/Loop/Utilities/PickerList.swift @@ -205,3 +205,24 @@ struct PickerSection: Identifiable, Hashable where V: Hashable, V: Identifiab self.items = items } } + +extension PickerSection where V == WindowDirection { + static var windowDirections: [PickerSection] { + [ + .init(String(localized: "General", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.general), + .init(String(localized: "Halves", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.halves), + .init(String(localized: "Quarters", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.quarters), + .init(String(localized: "Horizontal Thirds", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.horizontalThirds), + .init(String(localized: "Vertical Thirds", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.verticalThirds), + .init(String(localized: "Horizontal Fourths", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.horizontalFourths), + .init(String(localized: "Screen Switching", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.screenSwitching), + .init(String(localized: "Size Adjustment", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.sizeAdjustment), + .init(String(localized: "Shrink", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.shrink), + .init(String(localized: "Grow", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.grow), + .init(String(localized: "Move", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.move), + .init(String(localized: "Focus", comment: "Section header in the action picker of the Keybinds tab"), WindowDirection.focus), + .init(String(localized: "Stash", comment: "Section header in the action picker of the Keybinds tab"), [WindowDirection.stash, WindowDirection.unstash]), + .init(String(localized: "Go Back", comment: "Section header in the action picker of the Keybinds tab"), [WindowDirection.initialFrame, WindowDirection.undo]) + ] + } +} diff --git a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift index 6a7b12a2..698aa6c9 100644 --- a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift +++ b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift @@ -12,11 +12,10 @@ import SwiftUI struct LuminarePreviewView: View { @Environment(\.luminareAnimation) private var luminareAnimation @Environment(\.appearsActive) private var appearsActive - @ObservedObject var model: SettingsWindowManager = .shared + @EnvironmentObject private var windowModel: SettingsWindowManager @ObservedObject private var accentColorController: AccentColorController = .shared @State var actionRect: CGRect = .zero - @State private var scale: CGFloat = 1 @Default(.previewPadding) var previewPadding @Default(.padding) var padding @@ -52,23 +51,32 @@ struct LuminarePreviewView: View { .padding(previewPadding + previewBorderThickness / 2) .frame(width: actionRect.width, height: actionRect.height) .offset(x: actionRect.minX, y: actionRect.minY) - .scaleEffect(CGSize(width: scale, height: scale)) - .onAppear { - actionRect = model.previewedAction.getFrame(window: nil, bounds: .init(origin: .zero, size: geo.size), isPreview: true) - - withAnimation( - .interpolatingSpring( - duration: 0.2, - bounce: 0.1, - initialVelocity: 1 / 2 + .opacity(actionRect.size.area == .zero ? 0 : 1) + .onChange( + of: windowModel.previewedAction, + initial: true + ) { newAction in + let newActionRect: CGRect = if newAction.willManipulateExistingWindowFrame { + .zero + } else { + newAction.getFrame( + window: nil, + bounds: .init(origin: .zero, size: geo.size), + isPreview: true ) - ) { - scale = 1 } - } - .onChange(of: model.previewedAction) { _ in + withAnimation(animationConfiguration.previewTimingFunctionSwiftUI) { - actionRect = model.previewedAction.getFrame(window: nil, bounds: .init(origin: .zero, size: geo.size)) + if newActionRect.size.area == .zero { + actionRect = .init( + x: geo.size.width / 2, + y: geo.size.height / 2, + width: 0, + height: 0 + ) + } else { + actionRect = newActionRect + } } } } diff --git a/Loop/Window Action Indicators/Preview Window/PreviewController.swift b/Loop/Window Action Indicators/Preview Window/PreviewController.swift index 5d08363c..406dd55b 100644 --- a/Loop/Window Action Indicators/Preview Window/PreviewController.swift +++ b/Loop/Window Action Indicators/Preview Window/PreviewController.swift @@ -60,6 +60,11 @@ final class PreviewController { 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 diff --git a/Loop/Window Action Indicators/Radial Menu/RadialLayout.swift b/Loop/Window Action Indicators/Radial Menu/RadialLayout.swift new file mode 100644 index 00000000..d2099d4a --- /dev/null +++ b/Loop/Window Action Indicators/Radial Menu/RadialLayout.swift @@ -0,0 +1,29 @@ +// +// RadialLayout.swift +// Loop +// +// Created by Kai Azim on 2025-12-31. +// + +import SwiftUI + +struct RadialLayout: Layout { + func sizeThatFits(proposal: ProposedViewSize, subviews _: Subviews, cache _: inout ()) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + func placeSubviews(in bounds: CGRect, proposal _: ProposedViewSize, subviews: Subviews, cache _: inout ()) { + let radius = min(bounds.size.width, bounds.size.height) / 2 + let angle = Angle.degrees(360 / Double(subviews.count)).radians + + for (index, subview) in subviews.enumerated() { + let viewSize = subview.sizeThatFits(.unspecified) + + let xPos = cos(angle * Double(index) - .pi / 2) * (radius - viewSize.width / 2) + let yPos = sin(angle * Double(index) - .pi / 2) * (radius - viewSize.height / 2) + + let point = CGPoint(x: bounds.midX + xPos, y: bounds.midY + yPos) + subview.place(at: point, anchor: .center, proposal: .unspecified) + } + } +} diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift index c8ecc4c1..b8eea381 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuController.swift @@ -16,7 +16,7 @@ final class RadialMenuController { func open( position: CGPoint, window: Window?, - startingAction: WindowAction? + startingAction: WindowAction ) { if let windowController = controller { windowController.window?.orderFrontRegardless() diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift index 6c151e81..8984bbce 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift @@ -70,7 +70,7 @@ struct RadialMenuView: View { .shadow(radius: 10) .padding(20) .fixedSize() - .scaleEffect(viewModel.radialMenuScale) + .scaleEffect(viewModel.shouldFillRadialMenu ? 0.85 : 1.0) .animation(animationConfiguration.radialMenuSize, value: viewModel.currentAction) .animation(luminareAnimation, value: [accentColorController.color1, accentColorController.color2]) } @@ -79,24 +79,24 @@ struct RadialMenuView: View { ZStack { if viewModel.shouldFillRadialMenu { Color.white - } - - ZStack { - if radialMenuCornerRadius >= radialMenuSize / 2 - 2 { - DirectionSelectorCircleSegment( - angle: viewModel.angle, - radialMenuSize: radialMenuSize - ) - } else { - DirectionSelectorSquareSegment( - angle: viewModel.angle, - radialMenuCornerRadius: radialMenuCornerRadius, - radialMenuThickness: radialMenuThickness - ) + } else { + ZStack { + if radialMenuCornerRadius >= radialMenuSize / 2 - 2 { + DirectionSelectorCircleSegment( + angle: viewModel.angle, + radialMenuSize: radialMenuSize + ) + } else { + DirectionSelectorSquareSegment( + angle: viewModel.angle, + radialMenuCornerRadius: radialMenuCornerRadius, + radialMenuThickness: radialMenuThickness + ) + } } + .compositingGroup() + .opacity(viewModel.shouldHideDirectionSelector ? 0 : 1) } - .compositingGroup() - .opacity(viewModel.shouldHideDirectionSelector ? 0 : 1) } .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 9a4ca64f..a2c8396d 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -12,14 +12,17 @@ import SwiftUI /// 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. final class RadialMenuViewModel: ObservableObject { @Published private(set) var angle: Double - @Published private(set) var currentAction: WindowAction? + @Published private(set) var currentAction: WindowAction + + /// If a cycling action is chosen, this will represent the enclosing cycle action + @Published private(set) var parentAction: WindowAction? private var previousAction: WindowAction? private var window: Window? let previewMode: Bool init( - startingAction: WindowAction?, + startingAction: WindowAction, window: Window?, previewMode: Bool ) { @@ -34,22 +37,50 @@ final class RadialMenuViewModel: ObservableObject { recomputeAngle() } + private var effectiveWindowAction: WindowAction { + parentAction ?? currentAction + } + + private var radialMenuActions: [RadialMenuAction] { + RadialMenuAction.userConfiguredActions + } + + private var directionalRadialMenuActions: [RadialMenuAction] { + radialMenuActions.dropLast() + } + + private var centerRadialMenuAction: RadialMenuAction? { + radialMenuActions.last + } + var shouldFillRadialMenu: Bool { - currentAction?.direction.shouldFillRadialMenu ?? false + // If the user has the center action selected, then fill the radial menu + if effectiveWindowAction.id == centerRadialMenuAction?.associatedActionId { + return true + } + + guard !directionalRadialMenuActions.contains(where: { $0.associatedActionId == effectiveWindowAction.id }) else { + return false + } + + // Otherwise, default to the action's settings + return effectiveWindowAction.direction.shouldFillRadialMenu } var shouldHideDirectionSelector: Bool { - currentAction?.direction.hasRadialMenuAngle != true || currentAction?.direction.isCustomizable == true - } + // If the current action is a user-set radial menu action, always show the direction selector + if radialMenuActions.contains(where: { $0.associatedActionId == effectiveWindowAction.id }) { + return false + } - var radialMenuScale: CGFloat { - currentAction?.direction == .maximize ? 0.85 : 1 + // Otherwise, default to the action's settings + return currentAction.direction.hasRadialMenuAngle != true || currentAction.direction.isCustomizable == true } var radialMenuImage: Image? { if window == nil, !previewMode { return Image(systemName: "exclamationmark.triangle") - } else if let image = currentAction?.image { + } else if let image = currentAction.image { let image = image.withSymbolConfiguration(.init(pointSize: 20, weight: .bold)) ?? image return Image(nsImage: image) } else { @@ -61,26 +92,43 @@ final class RadialMenuViewModel: ObservableObject { window = newWindow } - func setAction(to action: WindowAction) { + func setAction(to action: WindowAction, parent: WindowAction? = nil) { previousAction = currentAction currentAction = action + parentAction = parent recomputeAngle() } func recomputeAngle() { - if let target = currentAction?.radialMenuAngle(window: window) { - let closestAngle: Angle = .degrees(angle).angleDifference(to: target) + guard let targetAngle = calculateTargetAngle() else { return } - let previousActionHadAngle = previousAction?.direction.hasRadialMenuAngle ?? false - let animate: Bool = abs(closestAngle.degrees) < 179 && previousActionHadAngle + let closestAngle = Angle.degrees(angle).angleDifference(to: targetAngle) + let shouldAnimate = shouldAnimateTransition(closestAngle: closestAngle) - let defaultAnimation = AnimationConfiguration.radialMenuAngle - let noAnimation = Animation.linear(duration: 0) + withAnimation(shouldAnimate ? AnimationConfiguration.radialMenuAngle : .linear(duration: 0)) { + angle += closestAngle.degrees + } + } - withAnimation(animate ? defaultAnimation : noAnimation) { - angle += closestAngle.degrees - } + private func calculateTargetAngle() -> Angle? { + // Check directional radial menu actions first + if let index = directionalRadialMenuActions.firstIndex(where: { $0.associatedActionId == effectiveWindowAction.id }) { + let actionAngleSpan = 360.0 / CGFloat(directionalRadialMenuActions.count) + return Angle(degrees: CGFloat(index) * actionAngleSpan - 90) } + + // Otherwise, default to the current action's radial menu angle + return currentAction.radialMenuAngle(window: window) + } + + private func shouldAnimateTransition(closestAngle: Angle) -> Bool { + guard abs(closestAngle.degrees) < 179 else { return false } + + if let previousAction { + return directionalRadialMenuActions.contains(where: { $0.associatedActionId == previousAction.id }) || previousAction.direction.hasRadialMenuAngle + } + + return false } } diff --git a/Loop/Window Management/Window Action/RadialMenuAction.swift b/Loop/Window Management/Window Action/RadialMenuAction.swift new file mode 100644 index 00000000..f31d3173 --- /dev/null +++ b/Loop/Window Management/Window Action/RadialMenuAction.swift @@ -0,0 +1,125 @@ +// +// RadialMenuAction.swift +// Loop +// +// Created by Kai Azim on 2025-11-11. +// + +import Defaults +import Foundation + +struct RadialMenuAction: Identifiable, Codable, Hashable, Defaults.Serializable { + let id: UUID + var type: ActionType + + enum ActionType: Identifiable, Codable, Hashable { + case custom(WindowAction) + case keybindReference(UUID) + + var id: UUID { + switch self { + case let .custom(windowAction): + windowAction.id + case let .keybindReference(id): + id + } + } + + var resolvedAction: WindowAction? { + switch self { + case let .custom(windowAction): + windowAction + case let .keybindReference(id): + if let action = Defaults[.keybinds].first(where: { $0.id == id }) { + action + } else { + nil + } + } + } + + var isKeybindReference: Bool { + switch self { + case .custom: + false + case .keybindReference: + true + } + } + } + + private init(id: UUID, type: ActionType) { + self.id = id + self.type = type + } + + static func custom(_ action: WindowAction) -> Self { + self.init( + id: .init(), + type: .custom(action) + ) + } + + static func keybindReference(_ id: UUID) -> Self { + self.init( + id: .init(), + type: .keybindReference(id) + ) + } + + // MARK: Computed Helpers + + var associatedActionId: UUID { + type.id + } + + var resolved: WindowAction? { + type.resolvedAction + } +} + +extension RadialMenuAction { + static let defaultRadialMenuActions: [RadialMenuAction] = [ + .custom( + WindowAction( + .init(localized: "Top Cycle"), + cycle: [.init(.topHalf), .init(.topThird), .init(.topTwoThirds)] + ) + ), + .custom(WindowAction(.topRightQuarter)), + .custom( + WindowAction( + .init(localized: "Right Cycle"), + cycle: [.init(.rightHalf), .init(.rightThird), .init(.rightTwoThirds)] + ) + ), + .custom(WindowAction(.bottomRightQuarter)), + .custom( + WindowAction( + .init(localized: "Bottom Cycle"), + cycle: [.init(.bottomHalf), .init(.bottomThird), .init(.bottomTwoThirds)] + ) + ), + .custom(WindowAction(.bottomLeftQuarter)), + .custom( + WindowAction( + .init(localized: "Left Cycle"), + cycle: [.init(.leftHalf), .init(.leftThird), .init(.leftTwoThirds)] + ) + ), + .custom(WindowAction(.topLeftQuarter)), + .custom( + WindowAction( + "\(WindowDirection.maximize.name) + \(WindowDirection.macOSCenter.name)", + cycle: [ + .init(.maximize), + .init(.macOSCenter) + ] + ) + ) + ] + + static var userConfiguredActions: [RadialMenuAction] { + Defaults[.enableRadialMenuCustomization] ? Defaults[.radialMenuActions] : defaultRadialMenuActions + } +} diff --git a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift deleted file mode 100644 index 4795fd8f..00000000 --- a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// RadialMenuWindowAction.swift -// Loop -// -// Created by Kai Azim on 2025-11-11. -// - -import Defaults -import Foundation - -enum RadialMenuWindowAction: Codable, Defaults.Serializable { - case custom(WindowAction) - case keybindReference(UUID) - - static let defaultRadialMenuActions: [RadialMenuWindowAction] = [ - .custom(.init([.init(.maximize), .init(.macOSCenter)])), - .custom(.init([.init(.rightHalf), .init(.rightThird), .init(.rightTwoThirds)])), - .custom(.init(.bottomRightQuarter)), - .custom(.init([.init(.bottomHalf), .init(.bottomThird), .init(.bottomTwoThirds)])), - .custom(.init(.bottomLeftQuarter)), - .custom(.init([.init(.leftHalf), .init(.leftThird), .init(.leftTwoThirds)])), - .custom(.init(.topLeftQuarter)), - .custom(.init([.init(.topHalf), .init(.topThird), .init(.topTwoThirds)])), - .custom(.init(.topRightQuarter)) - ] -} diff --git a/Loop/Window Management/Window Action/WindowAction+Image.swift b/Loop/Window Management/Window Action/WindowAction+Image.swift index 6298a7da..cae4ce16 100644 --- a/Loop/Window Management/Window Action/WindowAction+Image.swift +++ b/Loop/Window Management/Window Action/WindowAction+Image.swift @@ -11,6 +11,8 @@ import SwiftUI extension WindowAction { var image: NSImage? { switch direction { + case .noAction: + NSImage(systemSymbolName: "questionmark", accessibilityDescription: nil) case .undo: NSImage(systemSymbolName: "arrow.uturn.backward", accessibilityDescription: nil) case .initialFrame: @@ -152,7 +154,7 @@ final class IconRenderView: NSView { to action: WindowAction, animated: Bool ) { - guard !action.isSameManipulation(as: currentAction) else { return } + guard action.id != currentAction.id else { return } currentAction = action updatePath(duration: animated ? 0.2 : 0.0) } diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index 3ce452fc..fefd40a1 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -13,7 +13,8 @@ import SwiftUI /// /// Common actions, such as right half, or bottom right quarter, are represented by `WindowDirection` enum, while user-made actions, such as custom frames and cycles are speciied by this struct. struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serializable { - private(set) var id: UUID = .init() + private(set) var id: UUID + private static var sharedNoSelectionId: UUID = .init() /// Initializes a `WindowAction` with the specified parameters. Only to be used when decoding from JSON. /// - Parameters: @@ -61,6 +62,12 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial /// Initializes a `WindowAction` with the specified direction and an empty keybind. /// - Parameter direction: the direction of the window action. init(_ direction: WindowDirection, keybind: Set = []) { + if direction == .noSelection { + self.id = Self.sharedNoSelectionId + } else { + self.id = UUID() + } + self.direction = direction self.keybind = keybind } @@ -71,6 +78,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial /// - cycle: the cycle of window actions. This is an array of `WindowAction` that will be cycled through when the action is triggered. /// - keybind: the keybinds associated with this action. init(_ name: String? = nil, cycle: [WindowAction], keybind: Set = []) { + self.id = UUID() self.direction = .cycle self.name = name self.cycle = cycle @@ -103,32 +111,6 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial // MARK: - Methods - /// Determines if one action is equivalent to another, ignore all properties that are not related to resizing or moving the window. - /// - Parameter other: the other `WindowAction` to compare against. - /// - Returns: `true` if the two actions are equivalent in terms of resizing or moving the window, otherwise `false`. - func isSameManipulation(as other: WindowAction) -> Bool { - let commonID = UUID() - - /// Removes ID, keybind and name. This is useful when checking for equality between an otherwise identical keybind and radial menu action. - func stripNonResizingProperties(of action: WindowAction) -> WindowAction { - var strippedAction = action - strippedAction.id = commonID - strippedAction.keybind = [] - strippedAction.name = nil - - if let cycle = action.cycle { - strippedAction.cycle = cycle.map { stripNonResizingProperties(of: $0) } - } - - return strippedAction - } - - let modifiedSelf = stripNonResizingProperties(of: self) - let modifiedOther = stripNonResizingProperties(of: other) - - return modifiedSelf == modifiedOther - } - /// Retrieves the name of the action, either from the `name` property or from the `direction` enum. /// - Returns: the name of the action. func getName() -> String { @@ -138,7 +120,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial result = if let name, !name.isEmpty { name } else { - .init(localized: .init("Custom Keybind", defaultValue: "Custom Keybind")) + .init(localized: .init("Custom Action", defaultValue: "Custom Action")) } } else if direction == .stash { result = if let name, !name.isEmpty { @@ -216,7 +198,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial 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 result: Angle = .radians(angle) * -1 + let result: Angle = angle * -1 return result.normalized() } @@ -230,7 +212,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial /// - 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, .cycle, .minimize, .hide] + let noFrameActions: [WindowDirection] = [.noAction, .noSelection, .cycle, .minimize, .hide] guard !noFrameActions.contains(direction), !direction.willFocusWindow else { return NSRect(origin: bounds.center, size: .zero) } @@ -609,7 +591,6 @@ extension WindowAction { /// - Returns: the adjusted frame after applying the size adjustment based on the direction and bounds. private func calculateSizeAdjustment(_ frameToResizeFrom: CGRect, _ bounds: CGRect) -> CGRect { var result = frameToResizeFrom - let totalBounds: Edge.Set = [.top, .bottom, .leading, .trailing] let step = Defaults[.sizeIncrement] * ((direction == .larger || direction.willGrow) ? -1 : 1) let padding = PaddingSettings.padding @@ -621,20 +602,24 @@ extension WindowAction { if LoopManager.sidesToAdjust == nil { let edgesTouchingBounds = frameToResizeFrom.getEdgesTouchingBounds(bounds) - LoopManager.sidesToAdjust = totalBounds.subtracting(edgesTouchingBounds) + LoopManager.sidesToAdjust = .all.subtracting(edgesTouchingBounds) } if let edgesToInset = LoopManager.sidesToAdjust { - if edgesToInset.isEmpty || edgesToInset.contains(totalBounds) { - result = result.inset( - by: step, - minSize: .init( - width: minWidth, - height: minHeight + if edgesToInset.isEmpty || edgesToInset.contains(.all) { + result = result + .inset( + by: step, + minSize: .init( + width: minWidth, + height: minHeight + ) ) - ) + .intersection(bounds) } else { - result = result.padding(edgesToInset, step) + result = result + .padding(edgesToInset, step) + .intersection(bounds) if result.width < minWidth { result.size.width = minWidth diff --git a/Loop/Window Management/Window Action/WindowDirection+LocalizedString.swift b/Loop/Window Management/Window Action/WindowDirection+LocalizedString.swift index 38265762..188432b3 100644 --- a/Loop/Window Management/Window Action/WindowDirection+LocalizedString.swift +++ b/Loop/Window Management/Window Action/WindowDirection+LocalizedString.swift @@ -18,7 +18,7 @@ extension WindowDirection { var name: String { switch self { - case .noAction: + case .noAction, .noSelection: String(localized: "No Action", comment: "Window action: no selection") case .maximize: String(localized: "Maximize", comment: "Window action") diff --git a/Loop/Window Management/Window Action/WindowDirection.swift b/Loop/Window Management/Window Action/WindowDirection.swift index 5af27793..d9e27027 100644 --- a/Loop/Window Management/Window Action/WindowDirection.swift +++ b/Loop/Window Management/Window Action/WindowDirection.swift @@ -12,8 +12,13 @@ import SwiftUI enum WindowDirection: String, CaseIterable, Identifiable, Codable { var id: Self { self } + // "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" + // General Actions - case noAction = "NoAction", maximize = "Maximize", almostMaximize = "AlmostMaximize", fullscreen = "Fullscreen" + case maximize = "Maximize", almostMaximize = "AlmostMaximize", fullscreen = "Fullscreen" case maximizeHeight = "MaximizeHeight", maximizeWidth = "MaximizeWidth" case undo = "Undo", initialFrame = "InitialFrame", hide = "Hide", minimize = "Minimize", minimizeOthers = "MinimizeOthers" case macOSCenter = "MacOSCenter", center = "Center" @@ -92,7 +97,7 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { var isCustomizable: Bool { [.custom, .stash].contains(self) } var hasRadialMenuAngle: Bool { - let noAngleActions: [WindowDirection] = [.noAction, .minimize, .minimizeOthers, .hide, .initialFrame, .undo, .cycle] + let noAngleActions: [WindowDirection] = [.noAction, .noSelection, .minimize, .minimizeOthers, .hide, .initialFrame, .undo, .cycle] return !(noAngleActions.contains(self) || willChangeScreen || willAdjustSize || willShrink || willGrow || willMove || willFocusWindow || willMaximize || willCenter) } @@ -138,20 +143,6 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { } } - var nextPreviewDirection: WindowDirection { - switch self { - case .topHalf: .topRightQuarter - case .topRightQuarter: .rightHalf - case .rightHalf: .bottomRightQuarter - case .bottomRightQuarter: .bottomHalf - case .bottomHalf: .bottomLeftQuarter - case .bottomLeftQuarter: .leftHalf - case .leftHalf: .topLeftQuarter - case .topLeftQuarter: .maximize - default: .topHalf - } - } - var focusDirection: NavigationDirection? { switch self { case .focusLeft: .left diff --git a/Loop/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index 1813a758..a6176e30 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -23,7 +23,7 @@ enum WindowEngine { on screen: NSScreen, shouldRecord: Bool = true ) { - guard action.direction != .noAction, !action.direction.willFocusWindow else { return } + guard action.direction != .noAction, action.direction != .noSelection, !action.direction.willFocusWindow else { return } let willChangeScreens = ScreenUtility.screenContaining(window) != screen @@ -74,47 +74,45 @@ enum WindowEngine { if !Defaults[.previewVisibility] { LoopManager.lastTargetFrame = window.frame } - - return - } - - // Otherwise, we obviously need to disable fullscreen to resize the window - window.fullscreen = false - - // Calculate the target frame - let targetFrame: CGRect = action.getFrame( - window: window, - bounds: screen.safeScreenFrame, - screen: screen - ) - Log.info("Target window frame: \(targetFrame.debugDescription)", category: .windowEngine) - - // If the action is undo, remove the last action from the window records. - if action.direction == .undo { - WindowRecords.removeLastAction(for: window) - } - - // If the window is one of Loop's windows, resize it using the actual NSWindow, preventing crashes - if window.nsRunningApplication?.bundleIdentifier == Bundle.main.bundleIdentifier { - resizeOwnWindow(targetFrame: targetFrame) } else { - let shouldAnimate = shouldAnimateResize( - for: window, - willChangeScreens: willChangeScreens - ) - resizeWindow( - window, - targetFrame: targetFrame, - screen: screen, - willChangeScreens: willChangeScreens, - ignorePadding: action.direction.willMove, - animate: shouldAnimate + // Otherwise, we obviously need to disable fullscreen to resize the window + window.fullscreen = false + + // Calculate the target frame + let targetFrame: CGRect = action.getFrame( + window: window, + bounds: screen.safeScreenFrame, + screen: screen ) - } + Log.info("Target window frame: \(targetFrame.debugDescription)", category: .windowEngine) - // Move cursor to center of window if user has enabled it - if Defaults[.moveCursorWithWindow] { - CGWarpMouseCursorPosition(targetFrame.center) + // If the action is undo, remove the last action from the window records. + if action.direction == .undo { + WindowRecords.removeLastAction(for: window) + } + + // If the window is one of Loop's windows, resize it using the actual NSWindow, preventing crashes + if window.nsRunningApplication?.bundleIdentifier == Bundle.main.bundleIdentifier { + resizeOwnWindow(targetFrame: targetFrame) + } else { + let shouldAnimate = shouldAnimateResize( + for: window, + willChangeScreens: willChangeScreens + ) + resizeWindow( + window, + targetFrame: targetFrame, + screen: screen, + willChangeScreens: willChangeScreens, + ignorePadding: action.direction.willMove, + animate: shouldAnimate + ) + } + + // Move cursor to center of window if user has enabled it + if Defaults[.moveCursorWithWindow] { + CGWarpMouseCursorPosition(targetFrame.center) + } } StashManager.shared.onWindowResized(