From f61569e6de359ccd675d2df33318830713cbe2aa Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 20 Dec 2025 22:46:42 -0700 Subject: [PATCH 01/16] =?UTF-8?q?=E2=9C=A8=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop.xcodeproj/project.pbxproj | 23 -- .../Observers/MouseInteractionObserver.swift | 6 +- Loop/Extensions/CGGeometry+Extensions.swift | 5 +- Loop/Localizable.xcstrings | 28 +- .../{Item View => }/DirectionPickerView.swift | 38 +-- .../{Item View => }/KeybindItemView.swift | 9 +- .../Theming/RadialMenuActionItemView.swift | 318 ++++++++++++++++++ .../Theming/RadialMenuConfiguration.swift | 40 +++ .../Theming/RadialMenuIconView.swift | 91 +++++ .../CustomTextField.swift | 2 +- .../Item View => Utilities}/PickerList.swift | 21 ++ .../RadialMenuWindowAction.swift | 88 ++++- .../Window Action/WindowAction.swift | 2 +- 13 files changed, 597 insertions(+), 74 deletions(-) rename Loop/Settings Window/Settings/Keybinds/{Item View => }/DirectionPickerView.swift (54%) rename Loop/Settings Window/Settings/Keybinds/{Item View => }/KeybindItemView.swift (96%) create mode 100644 Loop/Settings Window/Theming/RadialMenuActionItemView.swift create mode 100644 Loop/Settings Window/Theming/RadialMenuIconView.swift rename Loop/{Settings Window/Settings/Keybinds/Item View => Utilities}/CustomTextField.swift (92%) rename Loop/{Settings Window/Settings/Keybinds/Item View => Utilities}/PickerList.swift (73%) 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/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index 6a47c037..d686f8f2 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -86,7 +86,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 @@ -110,13 +110,13 @@ 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 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/Localizable.xcstrings b/Loop/Localizable.xcstrings index c3bf6f55..a0ead0e4 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" : { @@ -2806,6 +2809,7 @@ } }, "Configure padding…" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3713,6 +3717,10 @@ } } }, + "Customize this action's custom frame." : { + "comment" : "A help text for the button that allows users to customize the custom frame of an action.", + "isCommentAutoGenerated" : true + }, "Customize this keybind's action." : { "localizations" : { "ar" : { @@ -3877,7 +3885,12 @@ } } }, + "Customize what this action cycles through." : { + "comment" : "A tooltip explaining that you can customize what an action cycles through.", + "isCommentAutoGenerated" : true + }, "Customize what this keybind cycles through." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -5360,6 +5373,10 @@ } } }, + "Failed to resolve keybind" : { + "comment" : "An error message displayed when a keybind reference in the radial menu cannot be resolved to a valid window action.", + "isCommentAutoGenerated" : true + }, "First Fourth" : { "comment" : "Window action", "localizations" : { @@ -15585,6 +15602,10 @@ } } }, + "No radial menu actions" : { + "comment" : "A message displayed when there are no radial menu actions configured.", + "isCommentAutoGenerated" : true + }, "No updates available message 01" : { "localizations" : { "ar" : { @@ -20426,6 +20447,10 @@ } } }, + "Press \"Add\" to add an action" : { + "comment" : "A description displayed when there are no radial menu actions. It instructs the user to add actions.", + "isCommentAutoGenerated" : true + }, "Press \"Add\" to add an application" : { "localizations" : { "ar" : { @@ -20923,6 +20948,7 @@ } }, "Quit" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -30000,5 +30026,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file 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 96% rename from Loop/Settings Window/Settings/Keybinds/Item View/KeybindItemView.swift rename to Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift index 17ceab3a..c0d7527a 100644 --- a/Loop/Settings Window/Settings/Keybinds/Item View/KeybindItemView.swift +++ b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift @@ -79,7 +79,7 @@ struct KeybindItemView: View { .frame(width: 400) } } - .help("Customize this keybind's custom frame.") + .help("Customize this action's custom frame.") } if action.direction == .cycle { @@ -93,7 +93,7 @@ struct KeybindItemView: View { 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/Theming/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/RadialMenuActionItemView.swift new file mode 100644 index 00000000..cd4a685c --- /dev/null +++ b/Loop/Settings Window/Theming/RadialMenuActionItemView.swift @@ -0,0 +1,318 @@ +// +// RadialMenuActionItemView.swift +// Loop +// +// Created by Kai Azim on 2025-12-08. +// + +import Defaults +import Luminare +import SwiftUI + +struct RadialMenuActionItemView: View { + @Environment(\.luminareItemBeingHovered) private var isHovering + @Environment(\.luminareAnimation) var luminareAnimation + @Default(.radialMenuActions) private var radialMenuActions + @Default(.keybinds) private var keybinds + + @Binding private var radialMenuAction: RadialMenuWindowAction + + @State private var isPickerPresented = false + @State private var isConfiguringCustom: Bool = false + @State private var isConfiguringCycle: Bool = false + + init(_ action: Binding) { + self._radialMenuAction = action + } + + var body: some View { + HStack { + label + + Spacer() + + if radialMenuAction.isKeybindReference { + Image(systemName: "link") + .foregroundStyle(.secondary) + } + +// RadialMenuIconView( +// totalItems: radialMenuActions.count, +// index: radialMenuActions.firstIndex(of: radialMenuAction) ?? 0 +// ) + } + .padding(.horizontal, 12) + .onChange(of: isHovering) { _ in + if !isHovering { + isPickerPresented = false + } + } + .onChange(of: radialMenuAction.resolvedAction) { _ in + if let resolvedAction = radialMenuAction.resolvedAction { + if resolvedAction.direction.isCustomizable { + isConfiguringCustom = true + } + if resolvedAction.direction == .cycle { + isConfiguringCycle = true + } + } + } + } + + @ViewBuilder + private var label: some View { + actionIndicator + .background { + if isHovering { + Color.clear + .luminarePopup( + isPresented: $isPickerPresented, + alignment: .leadingLastTextBaseline + ) { + RadialMenuActionPickerView(selection: $radialMenuAction) + } + .luminareSheetClosesOnDefocus(true) + } + } + } + + @ViewBuilder + var actionIndicator: some View { + HStack(spacing: 2) { + Button { + isPickerPresented = true + } label: { + HStack(spacing: 8) { + if let action = radialMenuAction.resolvedAction { +// IconView(action: action) + RadialMenuIconView( + totalItems: radialMenuActions.count, + index: radialMenuActions.firstIndex(of: radialMenuAction) ?? 0 + ) + + Text(action.getName()) + .fontWeight(.regular) + .lineLimit(1) + } else { +// Image(systemName: "bolt.horizontal.fill") +// .foregroundStyle(.secondary) + RadialMenuIconView( + totalItems: radialMenuActions.count, + index: radialMenuActions.firstIndex(of: radialMenuAction) ?? 0 + ) + + Text("Failed to resolve 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 = radialMenuAction.resolvedAction { + let actionBinding = Binding( + get: { + resolvedAction + }, + set: { newAction in + if radialMenuAction.isKeybindReference { + guard let index = radialMenuAction.keybindIndex else { + return + } + + keybinds[index] = newAction + } else { + radialMenuAction = .custom(newAction) + } + } + ) + + if resolvedAction.direction.isCustomizable { + Button(action: { + isConfiguringCustom = true + }, label: { + Image(systemName: "slider.horizontal.3") + }) + .buttonStyle(.plain) + .luminareModalWithPredefinedSheetStyle(isPresented: $isConfiguringCustom, isCompact: false) { + if resolvedAction.direction == .custom { + CustomActionConfigurationView(action: actionBinding, isPresented: $isConfiguringCustom) + .frame(width: 400) + } else { + StashActionConfigurationView(action: actionBinding, isPresented: $isConfiguringCustom) + .frame(width: 400) + } + } + .help("Customize this keybind's custom frame.") + } + + if resolvedAction.direction == .cycle { + Button(action: { + isConfiguringCycle = true + }, label: { + Image(systemName: "repeat") + }) + .buttonStyle(.plain) + .luminareModalWithPredefinedSheetStyle(isPresented: $isConfiguringCycle, isCompact: false) { + CycleActionConfigurationView(action: actionBinding, isPresented: $isConfiguringCycle) + .frame(width: 400) + } + .help("Customize what this action cycles through.") + } + } + } + .font(.title3) + .foregroundStyle(isHovering ? .primary : .secondary) + } + } +} + +struct RadialMenuActionPickerView: View { + @Default(.keybinds) private var keybinds + + private let padding: CGFloat = 12 + + @State private var searchText = "" + @State private var searchResults: [RadialMenuWindowAction] = [] + + @Binding private var selection: RadialMenuWindowAction + + private static let directionSections: [PickerSection] = { + let windowDirections = PickerSection.windowDirections + .map { section in + PickerSection( + section.title, + section.items.map { RadialMenuWindowAction.custom(.init($0)) } + ) + } + + return windowDirections + }() + + private var keybindsSection: PickerSection { + PickerSection( + "Your Keybinds", + keybinds.map { RadialMenuWindowAction.keybindReference($0.id) } + ) + } + + private var allSections: [PickerSection] { + Self.directionSections + [keybindsSection] + } + + private var allSectionItems: [RadialMenuWindowAction] { + 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: "link") + .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 -> (RadialMenuWindowAction, 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/RadialMenuConfiguration.swift b/Loop/Settings Window/Theming/RadialMenuConfiguration.swift index b7017a5b..8ac3aa0a 100644 --- a/Loop/Settings Window/Theming/RadialMenuConfiguration.swift +++ b/Loop/Settings Window/Theming/RadialMenuConfiguration.swift @@ -13,6 +13,8 @@ struct RadialMenuConfigurationView: View { @Default(.radialMenuVisibility) private var radialMenuVisibility @Default(.radialMenuCornerRadius) private var radialMenuCornerRadius @Default(.radialMenuThickness) private var radialMenuThickness + @Default(.radialMenuActions) private var radialMenuActions + @State private var selectedRadialMenuActions: Set = [] var body: some View { LuminareSection { @@ -51,5 +53,43 @@ struct RadialMenuConfigurationView: View { } } .animation(.smooth(duration: 0.25), value: radialMenuVisibility) + + LuminareSection(String(localized: "Actions", comment: "Section header 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) + } 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) + } } } diff --git a/Loop/Settings Window/Theming/RadialMenuIconView.swift b/Loop/Settings Window/Theming/RadialMenuIconView.swift new file mode 100644 index 00000000..0f1d37aa --- /dev/null +++ b/Loop/Settings Window/Theming/RadialMenuIconView.swift @@ -0,0 +1,91 @@ +// +// RadialMenuIconView.swift +// Loop +// +// Created by Kai Azim on 2025-12-08. +// + +import SwiftUI +import Defaults + +struct RadialMenuIconView: View { + private static let size: CGFloat = 18.0 + private static let normalSize: CGFloat = 100.0 + private static var miniScaleFactor: CGFloat { + Self.size / Self.normalSize + } + + @Default(.radialMenuCornerRadius) private var radialMenuCornerRadius + @Default(.radialMenuThickness) private var radialMenuThickness + + private var cornerRadius: CGFloat { + radialMenuCornerRadius * Self.miniScaleFactor + } + + private var thickness: CGFloat { + radialMenuThickness * Self.miniScaleFactor + 1 + } + + let totalItems: Int + let index: Int + + private var shouldFillRadialMenu: Bool { + index == totalItems - 1 + } + + private var degreesPerDirection: CGFloat { + 360.0 / Double(totalItems - 1) + } + + var body: some View { + Rectangle() + .mask { + border + .opacity(0.5) + + angleIndicator + } + .frame(width: 18, height: 18) + .drawingGroup() + } + + private var angleIndicator: some View { + ZStack { + if shouldFillRadialMenu { + Color.white + } else { + if cornerRadius >= 18.0 / 2.0 { + DirectionSelectorCircleSegment( + angle: Double(index) * degreesPerDirection - 90.0, + radialMenuSize: 18 + ) + } else { + DirectionSelectorSquareSegment( + angle: Double(index) * degreesPerDirection - 90.0, + radialMenuCornerRadius: cornerRadius, + radialMenuThickness: thickness + ) + } + } + } + .foregroundStyle(.white) + .mask { + RoundedRectangle(cornerRadius: cornerRadius) + .inset(by: thickness / 2) + .stroke(lineWidth: thickness) + .foregroundStyle(.white) + } + } + + private var border: some View { + ZStack { + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(lineWidth: 1) + + RoundedRectangle(cornerRadius: cornerRadius) + .inset(by: thickness - 1) + .strokeBorder(lineWidth: 1) + } + .foregroundStyle(.white) + } +} 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 Management/Window Action/RadialMenuWindowAction.swift b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift index 4795fd8f..96dc7c62 100644 --- a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift +++ b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift @@ -8,19 +8,87 @@ import Defaults import Foundation -enum RadialMenuWindowAction: Codable, Defaults.Serializable { +enum RadialMenuWindowAction: Identifiable, Codable, Hashable, Defaults.Serializable { 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 + } + } + + var keybindIndex: Int? { + switch self { + case .custom: + nil + case let .keybindReference(id): + Defaults[.keybinds].firstIndex { $0.id == id } + } + } + 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)) + .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) + ] + ) + ) ] } diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index 3ce452fc..7b72a4db 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -216,7 +216,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() } From bb77042d39c3b5a96b5513a8e972e5f746783a80 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 29 Dec 2025 22:42:52 -0700 Subject: [PATCH 02/16] Preview selected action in inspector pane --- Loop/Extensions/View+Extensions.swift | 55 ++++++++++++--- .../Keybinds/KeybindsConfigurationView.swift | 16 ++++- .../Settings Window/SettingsContentView.swift | 1 + .../SettingsWindowManager.swift | 20 ++++-- .../RadialMenuActionItemView.swift | 0 .../RadialMenuConfigurationView.swift} | 19 +++++- .../RadialMenuIconView.swift | 0 .../Preview Window/LuminarePreviewView.swift | 67 ++++++++++++++----- 8 files changed, 143 insertions(+), 35 deletions(-) rename Loop/Settings Window/Theming/{ => Radial Menu}/RadialMenuActionItemView.swift (100%) rename Loop/Settings Window/Theming/{RadialMenuConfiguration.swift => Radial Menu/RadialMenuConfigurationView.swift} (81%) rename Loop/Settings Window/Theming/{ => Radial Menu}/RadialMenuIconView.swift (100%) diff --git a/Loop/Extensions/View+Extensions.swift b/Loop/Extensions/View+Extensions.swift index 2973612b..10981605 100644 --- a/Loop/Extensions/View+Extensions.swift +++ b/Loop/Extensions/View+Extensions.swift @@ -8,16 +8,49 @@ import SwiftUI extension View { - // Make it easier to receive notifications SwiftUI views - func onReceive( - _ name: Notification.Name, - center: NotificationCenter = .default, - object: AnyObject? = nil, - perform action: @escaping (Notification) -> () - ) -> some View { - onReceive( - center.publisher(for: name, object: object), - perform: action - ) + @inlinable + @ViewBuilder + func onChange( + of value: V, + initial: Bool, + action: @escaping () -> Void + ) -> some View where V: Equatable { + if initial { + self + .onChange(of: value) { _ in + action() + } + .onAppear { + action() + } + } else { + self + .onChange(of: value) { _ in + action() + } + } + } + + @inlinable + @ViewBuilder + func onChange( + of value: V, + initial: Bool, + action: @escaping (V) -> Void + ) -> some View where V: Equatable { + if initial { + self + .onChange(of: value) { newValue in + action(newValue) + } + .onAppear { + action(value) + } + } else { + self + .onChange(of: value) { newValue in + action(newValue) + } + } } } diff --git a/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift index c454693c..13e23bb1 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,20 @@ struct KeybindsConfigurationView: View { .padding() } .luminareRoundingBehavior(bottom: true) + .onChange(of: model.selectedKeybinds, initial: true) { + if model.selectedKeybinds.count == 1, let action = model.selectedKeybinds.first { + if action.direction == .cycle { + windowModel.preferredPreviewedAction = action.cycle?.first + } else { + windowModel.preferredPreviewedAction = action + } + } else { + windowModel.preferredPreviewedAction = nil + } + } + .onDisappear { + windowModel.preferredPreviewedAction = nil + } } } } diff --git a/Loop/Settings Window/SettingsContentView.swift b/Loop/Settings Window/SettingsContentView.swift index 725a833c..e0c10515 100644 --- a/Loop/Settings Window/SettingsContentView.swift +++ b/Loop/Settings Window/SettingsContentView.swift @@ -74,5 +74,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 421a9a05..9c8a1614 100644 --- a/Loop/Settings Window/SettingsWindowManager.swift +++ b/Loop/Settings Window/SettingsWindowManager.swift @@ -17,7 +17,16 @@ final class SettingsWindowManager: ObservableObject { private var controller: NSWindowController? private var previewActionTimerTask: Task<(), Error>? - @Published private(set) var previewedAction: WindowAction + @Published private(set) var previewedAction: WindowAction { + didSet { + radialMenuViewModel.setAction(to: preferredPreviewedAction ?? previewedAction) + } + } + @Published var preferredPreviewedAction: WindowAction? { + didSet { + radialMenuViewModel.setAction(to: preferredPreviewedAction ?? previewedAction) + } + } @Published var showRadialMenu: Bool = false @Published var showPreview: Bool = false @@ -111,14 +120,11 @@ final class SettingsWindowManager: ObservableObject { private func startTimer() { previewActionTimerTask?.cancel() previewActionTimerTask = Task(priority: .utility) { - while true { + while !Task.isCancelled { try await Task.sleep(for: .seconds(1)) - if await controller?.window?.isKeyWindow == true, !Task.isCancelled { - await MainActor.run { - previewedAction.direction = previewedAction.direction.nextPreviewDirection - radialMenuViewModel.setAction(to: previewedAction) - } + if controller?.window?.isKeyWindow == true { + previewedAction.direction = previewedAction.direction.nextPreviewDirection } } } diff --git a/Loop/Settings Window/Theming/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift similarity index 100% rename from Loop/Settings Window/Theming/RadialMenuActionItemView.swift rename to Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift diff --git a/Loop/Settings Window/Theming/RadialMenuConfiguration.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift similarity index 81% rename from Loop/Settings Window/Theming/RadialMenuConfiguration.swift rename to Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index 8ac3aa0a..92b7419d 100644 --- a/Loop/Settings Window/Theming/RadialMenuConfiguration.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -1,5 +1,5 @@ // -// RadialMenuConfiguration.swift +// RadialMenuConfigurationView.swift // Loop // // Created by Kai Azim on 2024-04-19. @@ -10,6 +10,8 @@ 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 @@ -90,6 +92,21 @@ struct RadialMenuConfigurationView: View { .padding() } .luminareRoundingBehavior(bottom: true) + .onChange(of: selectedRadialMenuActions, initial: true) { + if selectedRadialMenuActions.count == 1, + let resolved = selectedRadialMenuActions.first?.resolvedAction { + if resolved.direction == .cycle { + windowModel.preferredPreviewedAction = resolved.cycle?.first + } else { + windowModel.preferredPreviewedAction = resolved + } + } else { + windowModel.preferredPreviewedAction = nil + } + } + .onDisappear { + windowModel.preferredPreviewedAction = nil + } } } } diff --git a/Loop/Settings Window/Theming/RadialMenuIconView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuIconView.swift similarity index 100% rename from Loop/Settings Window/Theming/RadialMenuIconView.swift rename to Loop/Settings Window/Theming/Radial Menu/RadialMenuIconView.swift diff --git a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift index 6a7b12a2..82bd8982 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,25 +51,63 @@ 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) + .opacity(actionRect.size.area == .zero ? 0 : 1) + .onChange( + of: windowModel.preferredPreviewedAction ?? windowModel.previewedAction, + initial: true + ) { newAction in + var newActionRect: CGRect - withAnimation( - .interpolatingSpring( - duration: 0.2, - bounce: 0.1, - initialVelocity: 1 / 2 + if newAction.willManipulateExistingWindowFrame { + newActionRect = .zero + } else { + newActionRect = 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 + } } } } } + + private func processNewRect(animate: Bool) { + var newActionRect: CGRect + + if newAction.willManipulateExistingWindowFrame { + newActionRect = .zero + } else { + newActionRect = newAction.getFrame( + window: nil, + bounds: .init(origin: .zero, size: geo.size), + isPreview: true + ) + } + + withAnimation(animate ? animationConfiguration.previewTimingFunctionSwiftUI : .none) { + if newActionRect.size.area == .zero { + actionRect = .init( + x: geo.size.width / 2, + y: geo.size.height / 2, + width: 0, + height: 0 + ) + } else { + actionRect = newActionRect + } + } + } } From 645f4baaaad8bfd2d626b1635abccaa079cc6e14 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 2 Jan 2026 15:47:18 -0700 Subject: [PATCH 03/16] =?UTF-8?q?=E2=9C=A8=20Auto-select=20item=20in=20lis?= =?UTF-8?q?t=20if=20selected=20in=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Keybinds/KeybindsConfigurationView.swift | 11 +- .../Settings Window/SettingsContentView.swift | 15 ++- .../SettingsWindowManager.swift | 64 +++++++-- .../RadialMenuActionItemView.swift | 22 +-- .../Radial Menu/RadialMenuActionsGuide.swift | 125 ++++++++++++++++++ .../RadialMenuConfigurationView.swift | 39 ++++-- .../Preview Window/LuminarePreviewView.swift | 29 +--- .../Radial Menu/RadialLayout.swift | 29 ++++ .../Radial Menu/RadialMenuViewModel.swift | 6 +- .../Window Action/WindowDirection.swift | 14 -- 10 files changed, 256 insertions(+), 98 deletions(-) create mode 100644 Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift create mode 100644 Loop/Window Action Indicators/Radial Menu/RadialLayout.swift diff --git a/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift b/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift index 13e23bb1..f4fa7096 100644 --- a/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift +++ b/Loop/Settings Window/Settings/Keybinds/KeybindsConfigurationView.swift @@ -165,17 +165,14 @@ struct KeybindsConfigurationView: View { .luminareRoundingBehavior(bottom: true) .onChange(of: model.selectedKeybinds, initial: true) { if model.selectedKeybinds.count == 1, let action = model.selectedKeybinds.first { - if action.direction == .cycle { - windowModel.preferredPreviewedAction = action.cycle?.first - } else { - windowModel.preferredPreviewedAction = action - } + windowModel.isPreviewingUserSelection = true + windowModel.setPreviewedAction(to: action) } else { - windowModel.preferredPreviewedAction = nil + windowModel.isPreviewingUserSelection = false } } .onDisappear { - windowModel.preferredPreviewedAction = nil + windowModel.isPreviewingUserSelection = false } } } diff --git a/Loop/Settings Window/SettingsContentView.swift b/Loop/Settings Window/SettingsContentView.swift index e0c10515..4e4db833 100644 --- a/Loop/Settings Window/SettingsContentView.swift +++ b/Loop/Settings Window/SettingsContentView.swift @@ -51,18 +51,21 @@ struct SettingsContentView: View { if model.showInspector { ZStack { - if model.showPreview { - LuminarePreviewView() - } + LuminarePreviewView() + .allowsHitTesting(false) if model.showRadialMenu { - VStack { - RadialMenuView(viewModel: model.radialMenuViewModel) + if model.currentTab == .radialMenu { + RadialMenuActionsGuide() } - .frame(maxHeight: .infinity, alignment: .center) + + RadialMenuView(viewModel: model.radialMenuViewModel) + .allowsHitTesting(false) } } + .compositingGroup() .animation(animation, value: [model.showRadialMenu, model.showPreview]) + .padding(12) .frame(width: 520) } } diff --git a/Loop/Settings Window/SettingsWindowManager.swift b/Loop/Settings Window/SettingsWindowManager.swift index 9c8a1614..40031b77 100644 --- a/Loop/Settings Window/SettingsWindowManager.swift +++ b/Loop/Settings Window/SettingsWindowManager.swift @@ -17,15 +17,12 @@ final class SettingsWindowManager: ObservableObject { private var controller: NSWindowController? private var previewActionTimerTask: Task<(), Error>? - @Published private(set) var previewedAction: WindowAction { - didSet { - radialMenuViewModel.setAction(to: preferredPreviewedAction ?? previewedAction) - } + @Published var isPreviewingUserSelection: Bool = false { + didSet { restartTimer() } } - @Published var preferredPreviewedAction: WindowAction? { - didSet { - radialMenuViewModel.setAction(to: preferredPreviewedAction ?? previewedAction) - } + @Published private(set) var previewedParentAction: WindowAction? = nil + @Published private(set) var previewedAction: WindowAction { + didSet { radialMenuViewModel.setAction(to: previewedAction, parent: previewedParentAction) } } @Published var showRadialMenu: Bool = false @@ -63,7 +60,7 @@ 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) @@ -116,16 +113,23 @@ 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 !Task.isCancelled { - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .seconds(1)) + while !Task.isCancelled { if controller?.window?.isKeyWindow == true { - previewedAction.direction = previewedAction.direction.nextPreviewDirection + setNextPreviewedAction() } + + try await Task.sleep(for: .seconds(1)) } } } @@ -134,4 +138,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] = Defaults[.radialMenuActions] + .map { $0.resolvedAction ?? .init(.noAction) } + + 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/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift index cd4a685c..574edbc7 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -10,6 +10,7 @@ import Luminare import SwiftUI struct RadialMenuActionItemView: View { + @EnvironmentObject private var windowModel: SettingsWindowManager @Environment(\.luminareItemBeingHovered) private var isHovering @Environment(\.luminareAnimation) var luminareAnimation @Default(.radialMenuActions) private var radialMenuActions @@ -30,16 +31,11 @@ struct RadialMenuActionItemView: View { label Spacer() - + if radialMenuAction.isKeybindReference { Image(systemName: "link") .foregroundStyle(.secondary) } - -// RadialMenuIconView( -// totalItems: radialMenuActions.count, -// index: radialMenuActions.firstIndex(of: radialMenuAction) ?? 0 -// ) } .padding(.horizontal, 12) .onChange(of: isHovering) { _ in @@ -84,22 +80,14 @@ struct RadialMenuActionItemView: View { } label: { HStack(spacing: 8) { if let action = radialMenuAction.resolvedAction { -// IconView(action: action) - RadialMenuIconView( - totalItems: radialMenuActions.count, - index: radialMenuActions.firstIndex(of: radialMenuAction) ?? 0 - ) + IconView(action: action) Text(action.getName()) .fontWeight(.regular) .lineLimit(1) } else { -// Image(systemName: "bolt.horizontal.fill") -// .foregroundStyle(.secondary) - RadialMenuIconView( - totalItems: radialMenuActions.count, - index: radialMenuActions.firstIndex(of: radialMenuAction) ?? 0 - ) + Image(systemName: "bolt.horizontal.fill") + .foregroundStyle(.secondary) Text("Failed to resolve keybind") .foregroundStyle(.secondary) 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..4039c09d --- /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 SwiftUI +import Luminare +import Defaults + +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: [RadialMenuWindowAction] { + Array(radialMenuActions.dropLast()) + } + + private var centerAction: RadialMenuWindowAction { + 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.resolvedAction { + 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.resolvedAction { + 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 index 92b7419d..224a4c6a 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -92,21 +92,34 @@ struct RadialMenuConfigurationView: View { .padding() } .luminareRoundingBehavior(bottom: true) - .onChange(of: selectedRadialMenuActions, initial: true) { - if selectedRadialMenuActions.count == 1, - let resolved = selectedRadialMenuActions.first?.resolvedAction { - if resolved.direction == .cycle { - windowModel.preferredPreviewedAction = resolved.cycle?.first - } else { - windowModel.preferredPreviewedAction = resolved - } - } else { - windowModel.preferredPreviewedAction = nil - } - } + .onChange(of: selectedRadialMenuActions, perform: userSelectionChanged) + .onChange(of: windowModel.previewedParentAction ?? windowModel.previewedAction, perform: previewedActionChanged) .onDisappear { - windowModel.preferredPreviewedAction = nil + windowModel.isPreviewingUserSelection = false } } } + + private func userSelectionChanged(_ newValue: Set) { + if newValue.count == 1, let resolved = newValue.first?.resolvedAction { + windowModel.isPreviewingUserSelection = true + windowModel.setPreviewedAction(to: resolved) + } else { + windowModel.isPreviewingUserSelection = false + } + } + + private func previewedActionChanged(_ newValue: WindowAction) { + guard windowModel.isPreviewingUserSelection else { + return + } + + let selectedAction = windowModel.previewedParentAction ?? windowModel.previewedAction + + if let match = radialMenuActions.first(where: { $0.id == selectedAction.id }) { + selectedRadialMenuActions = [match] + } else { + selectedRadialMenuActions = [] + } + } } diff --git a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift index 82bd8982..417dfc32 100644 --- a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift +++ b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift @@ -53,7 +53,7 @@ struct LuminarePreviewView: View { .offset(x: actionRect.minX, y: actionRect.minY) .opacity(actionRect.size.area == .zero ? 0 : 1) .onChange( - of: windowModel.preferredPreviewedAction ?? windowModel.previewedAction, + of: windowModel.previewedAction, initial: true ) { newAction in var newActionRect: CGRect @@ -83,31 +83,4 @@ struct LuminarePreviewView: View { } } } - - private func processNewRect(animate: Bool) { - var newActionRect: CGRect - - if newAction.willManipulateExistingWindowFrame { - newActionRect = .zero - } else { - newActionRect = newAction.getFrame( - window: nil, - bounds: .init(origin: .zero, size: geo.size), - isPreview: true - ) - } - - withAnimation(animate ? animationConfiguration.previewTimingFunctionSwiftUI : .none) { - 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/Radial Menu/RadialLayout.swift b/Loop/Window Action Indicators/Radial Menu/RadialLayout.swift new file mode 100644 index 00000000..e157be91 --- /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 Void) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + 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/RadialMenuViewModel.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift index 9a4ca64f..e0baf471 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -13,6 +13,9 @@ import SwiftUI final class RadialMenuViewModel: ObservableObject { @Published private(set) var angle: Double @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? @@ -61,9 +64,10 @@ 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() } diff --git a/Loop/Window Management/Window Action/WindowDirection.swift b/Loop/Window Management/Window Action/WindowDirection.swift index 5af27793..7b0d3d5e 100644 --- a/Loop/Window Management/Window Action/WindowDirection.swift +++ b/Loop/Window Management/Window Action/WindowDirection.swift @@ -138,20 +138,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 From 3a2aff110ed4352273476d8b86d7699fe1412bae Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 2 Jan 2026 16:21:35 -0700 Subject: [PATCH 04/16] =?UTF-8?q?=F0=9F=90=9E=20Fix=20radial=20menu=20acti?= =?UTF-8?q?on=20angle=20calculations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 15 ++-- .../Observers/Helpers/DoubleClickTimer.swift | 6 +- .../Observers/Helpers/TriggerDelayTimer.swift | 12 +-- Loop/Core/Observers/KeybindTrigger.swift | 13 +-- Loop/Core/Observers/MiddleClickTrigger.swift | 12 +-- .../Radial Menu/RadialMenuController.swift | 2 +- .../Radial Menu/RadialMenuView.swift | 2 +- .../Radial Menu/RadialMenuViewModel.swift | 81 ++++++++++++++----- 8 files changed, 90 insertions(+), 53 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index b1ab8285..5da9568b 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, 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..3a52d5fa 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(.noAction) + 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(.noAction) } } diff --git a/Loop/Core/Observers/KeybindTrigger.swift b/Loop/Core/Observers/KeybindTrigger.swift index 893137f0..2ee98af7 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(.noAction), + 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..c10acf02 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(.noAction)) } 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(.noAction)) } else if useTriggerDelay { - triggerDelayTimer.handleTrigger(startingAction: nil) + triggerDelayTimer.handleTrigger(startingAction: .init(.noAction)) } else { - openCallback(nil) + openCallback(.init(.noAction)) } } else { triggerDelayTimer.cancel() 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 13fa51a5..a680e27e 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]) } diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift index e0baf471..433d1cca 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -12,7 +12,7 @@ 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? @@ -22,7 +22,7 @@ final class RadialMenuViewModel: ObservableObject { let previewMode: Bool init( - startingAction: WindowAction?, + startingAction: WindowAction, window: Window?, previewMode: Bool ) { @@ -36,23 +36,51 @@ final class RadialMenuViewModel: ObservableObject { recomputeAngle() } + + private var effectiveWindowAction: WindowAction { + parentAction ?? currentAction + } + + private var radialMenuActions: [RadialMenuWindowAction] { + Defaults[.radialMenuActions] + } - var shouldFillRadialMenu: Bool { - currentAction?.direction.shouldFillRadialMenu ?? false + private var directionalRadialMenuActions: [RadialMenuWindowAction] { + radialMenuActions.dropLast() + } + + private var centerRadialMenuAction: RadialMenuWindowAction? { + radialMenuActions.last } - var shouldHideDirectionSelector: Bool { - currentAction?.direction.hasRadialMenuAngle != true || currentAction?.direction.isCustomizable == true + var shouldFillRadialMenu: Bool { + // If the user has the center action selected, then fill the radial menu + if effectiveWindowAction.id == centerRadialMenuAction?.id { + return true + } + + guard !directionalRadialMenuActions.contains(where: { $0.id == effectiveWindowAction.id }) else { + return false + } + + // Otherwise, default to the action's settings + return effectiveWindowAction.direction.shouldFillRadialMenu } - var radialMenuScale: CGFloat { - currentAction?.direction == .maximize ? 0.85 : 1 + var shouldHideDirectionSelector: Bool { + // If the current action is a user-set radial menu action, always show the direction selector + if radialMenuActions.contains(where: { $0.id == effectiveWindowAction.id }) { + return false + } + + // 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 { @@ -73,18 +101,29 @@ final class RadialMenuViewModel: ObservableObject { } func recomputeAngle() { - if let target = currentAction?.radialMenuAngle(window: window) { - let closestAngle: Angle = .degrees(angle).angleDifference(to: target) - - let previousActionHadAngle = previousAction?.direction.hasRadialMenuAngle ?? false - let animate: Bool = abs(closestAngle.degrees) < 179 && previousActionHadAngle - - let defaultAnimation = AnimationConfiguration.radialMenuAngle - let noAnimation = Animation.linear(duration: 0) - - withAnimation(animate ? defaultAnimation : noAnimation) { - angle += closestAngle.degrees - } + guard let targetAngle = calculateTargetAngle() else { return } + + let closestAngle = Angle.degrees(angle).angleDifference(to: targetAngle) + let shouldAnimate = shouldAnimateTransition(closestAngle: closestAngle) + + withAnimation(shouldAnimate ? AnimationConfiguration.radialMenuAngle : . linear(duration: 0)) { + angle += closestAngle.degrees } } + + private func calculateTargetAngle() -> Angle? { + // Check directional radial menu actions first + if let index = directionalRadialMenuActions.firstIndex(where: { $0.id == 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 { + let previousActionHadAngle = previousAction?.direction.hasRadialMenuAngle ?? false + return abs(closestAngle.degrees) < 179 && previousActionHadAngle + } } From 804a079446960f81f4a40ec8e9e71a1c899fc406 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 2 Jan 2026 16:59:59 -0700 Subject: [PATCH 05/16] =?UTF-8?q?=E2=9C=A8=20Differentiate=20no=20action?= =?UTF-8?q?=20vs=20no=20selection=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/LoopManager.swift | 2 +- Loop/Core/Observers/KeybindTrigger.swift | 2 +- .../Observers/MouseInteractionObserver.swift | 2 +- .../Settings Window/SettingsContentView.swift | 6 +-- Loop/Stashing/StashManager.swift | 2 +- Loop/Stashing/StashedWindowStore.swift | 2 +- .../Radial Menu/RadialMenuViewModel.swift | 9 ++++- .../Window Action/WindowAction+Image.swift | 6 ++- .../Window Action/WindowAction.swift | 40 +++++-------------- .../WindowDirection+LocalizedString.swift | 2 +- .../Window Action/WindowDirection.swift | 9 ++++- .../Window Manipulation/WindowEngine.swift | 2 +- 12 files changed, 39 insertions(+), 45 deletions(-) diff --git a/Loop/Core/LoopManager.swift b/Loop/Core/LoopManager.swift index 5da9568b..26c06cd9 100644 --- a/Loop/Core/LoopManager.swift +++ b/Loop/Core/LoopManager.swift @@ -231,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/KeybindTrigger.swift b/Loop/Core/Observers/KeybindTrigger.swift index 2ee98af7..0c28b552 100644 --- a/Loop/Core/Observers/KeybindTrigger.swift +++ b/Loop/Core/Observers/KeybindTrigger.swift @@ -194,7 +194,7 @@ final class KeybindTrigger { // Only trigger Loop without an action if the only pressed keys perfectly matches the trigger key. if allPressedKeys == triggerKey { openLoop( - startingAction: .init(.noAction), + startingAction: .init(.noSelection), overrideExistingTriggerDelayTimerAction: !isARepeat ) return .opening diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index d686f8f2..64400840 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -126,7 +126,7 @@ final class MouseInteractionObserver { case let .keybindReference(id): if let action = windowActionCache.actionsByIdentifier[id] { changeAction(action) } case nil: - changeAction(.init(.noAction)) + changeAction(.init(.noSelection)) } } } diff --git a/Loop/Settings Window/SettingsContentView.swift b/Loop/Settings Window/SettingsContentView.swift index 4e4db833..b9610a97 100644 --- a/Loop/Settings Window/SettingsContentView.swift +++ b/Loop/Settings Window/SettingsContentView.swift @@ -55,12 +55,12 @@ struct SettingsContentView: View { .allowsHitTesting(false) if model.showRadialMenu { + RadialMenuView(viewModel: model.radialMenuViewModel) + .allowsHitTesting(false) + if model.currentTab == .radialMenu { RadialMenuActionsGuide() } - - RadialMenuView(viewModel: model.radialMenuViewModel) - .allowsHitTesting(false) } } .compositingGroup() 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..9c156282 100644 --- a/Loop/Stashing/StashedWindowStore.swift +++ b/Loop/Stashing/StashedWindowStore.swift @@ -53,7 +53,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 } } diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift index 433d1cca..02f3cb0b 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -123,7 +123,12 @@ final class RadialMenuViewModel: ObservableObject { } private func shouldAnimateTransition(closestAngle: Angle) -> Bool { - let previousActionHadAngle = previousAction?.direction.hasRadialMenuAngle ?? false - return abs(closestAngle.degrees) < 179 && previousActionHadAngle + guard abs(closestAngle.degrees) < 179 else { return false } + + if let previousAction { + return directionalRadialMenuActions.contains(where: { $0.id == previousAction.id }) || previousAction.direction.hasRadialMenuAngle + } + + return false } } diff --git a/Loop/Window Management/Window Action/WindowAction+Image.swift b/Loop/Window Management/Window Action/WindowAction+Image.swift index 6298a7da..b5682f57 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) } @@ -274,7 +276,7 @@ final class IconRenderView: NSView { if currentAction.direction == .cycle, let image = NSImage(systemSymbolName: "repeat", accessibilityDescription: nil) { return .image(image) } - + return nil } diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index 7b72a4db..ef7b35b1 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -13,8 +13,9 @@ 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: /// - direction: the direction of the window action. If custom or cycle, use those and further specify the action with the parameters below. @@ -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 { @@ -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) } 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 7b0d3d5e..443a3709 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) } diff --git a/Loop/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index 1813a758..1a9c737b 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 From 84d39d7b01bac087719e8f61d40a787ad5897511 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 2 Jan 2026 17:47:38 -0700 Subject: [PATCH 06/16] =?UTF-8?q?=E2=9C=A8=20Defaults=20`enableRadialMenuC?= =?UTF-8?q?ustomization`=20key,=20settings=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Observers/MouseInteractionObserver.swift | 12 +- Loop/Extensions/Defaults+Extensions.swift | 1 + Loop/Extensions/View+Extensions.swift | 52 +++---- Loop/Localizable.xcstrings | 33 +++- .../Loop/AdvancedConfiguration.swift | 143 ++++++++++++------ .../Settings/Keybinds/KeybindItemView.swift | 12 +- .../SettingsWindowManager.swift | 24 +-- .../RadialMenuActionItemView.swift | 16 +- .../Radial Menu/RadialMenuActionsGuide.swift | 18 +-- .../RadialMenuConfigurationView.swift | 8 +- .../Radial Menu/RadialMenuIconView.swift | 28 ++-- .../Preview Window/LuminarePreviewView.swift | 10 +- .../Radial Menu/RadialLayout.swift | 4 +- .../Radial Menu/RadialMenuViewModel.swift | 32 ++-- .../RadialMenuWindowAction.swift | 6 + .../Window Action/WindowAction+Image.swift | 2 +- .../Window Action/WindowAction.swift | 4 +- .../Window Action/WindowDirection.swift | 2 +- 18 files changed, 246 insertions(+), 161 deletions(-) diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index 64400840..aff9ff79 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -24,9 +24,11 @@ final class MouseInteractionObserver { private var previousDistanceToMouse: CGFloat = .zero private var radialMenuActions: [RadialMenuWindowAction] { - Defaults[.radialMenuActions] + RadialMenuWindowAction.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 = initialMousePosition.angle(to: currentMousePosition) + .radians(.pi / 2) + let angleToMouse = initialMousePosition.angle(to: currentMousePosition) + .radians(.pi / 2) let distanceToMouse = initialMousePosition.distance(to: currentMousePosition) // Return if the mouse didn't move @@ -124,7 +126,11 @@ final class MouseInteractionObserver { 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(.noSelection)) } diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 5bff42a8..fef7002d 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) diff --git a/Loop/Extensions/View+Extensions.swift b/Loop/Extensions/View+Extensions.swift index 10981605..48a12a11 100644 --- a/Loop/Extensions/View+Extensions.swift +++ b/Loop/Extensions/View+Extensions.swift @@ -10,47 +10,43 @@ import SwiftUI extension View { @inlinable @ViewBuilder - func onChange( - of value: V, + func onChange( + of value: some Equatable, initial: Bool, - action: @escaping () -> Void - ) -> some View where V: Equatable { + action: @escaping () -> () + ) -> some View { if initial { - self - .onChange(of: value) { _ in - action() - } - .onAppear { - action() - } + onChange(of: value) { _ in + action() + } + .onAppear { + action() + } } else { - self - .onChange(of: value) { _ in - action() - } + onChange(of: value) { _ in + action() + } } } - + @inlinable @ViewBuilder func onChange( of value: V, initial: Bool, - action: @escaping (V) -> Void + action: @escaping (V) -> () ) -> some View where V: Equatable { if initial { - self - .onChange(of: value) { newValue in - action(newValue) - } - .onAppear { - action(value) - } + onChange(of: value) { newValue in + action(newValue) + } + .onAppear { + action(value) + } } else { - self - .onChange(of: value) { newValue in - action(newValue) - } + onChange(of: value) { newValue in + action(newValue) + } } } } diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index a0ead0e4..f97d8a7b 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -745,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" : { @@ -2312,6 +2316,10 @@ } } }, + "Cancel" : { + "comment" : "The label of a button that cancels an action.", + "isCommentAutoGenerated" : true + }, "Center" : { "comment" : "Window action", "localizations" : { @@ -5373,8 +5381,8 @@ } } }, - "Failed to resolve keybind" : { - "comment" : "An error message displayed when a keybind reference in the radial menu cannot be resolved to a valid window action.", + "Failed to resolve linked keybind" : { + "comment" : "A message displayed when a linked keybind in a radial menu action cannot be resolved.", "isCommentAutoGenerated" : true }, "First Fourth" : { @@ -21195,6 +21203,9 @@ } } }, + "Radial Menu" : { + "comment" : "Section header shown in settings" + }, "Relaxed" : { "comment" : "Animation speed setting", "localizations" : { @@ -21607,6 +21618,16 @@ } } } + }, + "Reset keybinds?" : { + "comment" : "An alert title that asks the user if they want to reset all keybinds.", + "isCommentAutoGenerated" : true + }, + "Reset radial menu actions" : { + + }, + "Reset radial menu actions?" : { + }, "Resize window under cursor" : { "localizations" : { @@ -26720,6 +26741,14 @@ } } }, + "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 clear all custom actions.", + "isCommentAutoGenerated" : true + }, "To save power, window animations are\nunavailable in Low Power Mode." : { "localizations" : { "ar" : { diff --git a/Loop/Settings Window/Loop/AdvancedConfiguration.swift b/Loop/Settings Window/Loop/AdvancedConfiguration.swift index f7ad1f30..7ef78a7d 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,55 +91,34 @@ 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 { + @EnvironmentObject private var windowModel: SettingsWindowManager + @Environment(\.luminareTintColor) var tint @Environment(\.luminareAnimation) var luminareAnimation @Environment(\.openURL) private var openURL @@ -148,21 +133,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 +187,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 +201,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(tint) + .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,7 +253,7 @@ struct AdvancedConfigurationView: View { HStack { Text("Import") - if model.didImportSuccessfullyAlert { + if model.showImportKeybindsSuccessIndicator { Image(systemName: "checkmark") .foregroundStyle(tint) .bold() @@ -229,7 +266,7 @@ struct AdvancedConfigurationView: View { HStack { Text("Export") - if model.didExportSuccessfullyAlert { + if model.showExportKeybindsSuccessIndicator { Image(systemName: "checkmark") .foregroundStyle(tint) .bold() @@ -237,11 +274,13 @@ struct AdvancedConfigurationView: View { } } - 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) .bold() @@ -249,6 +288,12 @@ struct AdvancedConfigurationView: View { } } .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.") + } } } } diff --git a/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift index c0d7527a..27b3bf53 100644 --- a/Loop/Settings Window/Settings/Keybinds/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 { @@ -83,11 +83,11 @@ struct KeybindItemView: View { } 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) diff --git a/Loop/Settings Window/SettingsWindowManager.swift b/Loop/Settings Window/SettingsWindowManager.swift index 40031b77..f96427f4 100644 --- a/Loop/Settings Window/SettingsWindowManager.swift +++ b/Loop/Settings Window/SettingsWindowManager.swift @@ -20,8 +20,9 @@ final class SettingsWindowManager: ObservableObject { @Published var isPreviewingUserSelection: Bool = false { didSet { restartTimer() } } + @Published private(set) var previewedParentAction: WindowAction? = nil - @Published private(set) var previewedAction: WindowAction { + @Published private(set) var previewedAction: WindowAction = .init(.noSelection) { didSet { radialMenuViewModel.setAction(to: previewedAction, parent: previewedParentAction) } } @@ -62,8 +63,11 @@ final class SettingsWindowManager: ObservableObject { private init() { let startingAction: WindowAction = .init(.noAction) - self.previewedAction = startingAction self.radialMenuViewModel = .init(startingAction: startingAction, window: nil, previewMode: true) + + if let firstAction = RadialMenuWindowAction.userConfiguredActions.first?.resolvedAction { + setPreviewedAction(to: firstAction) + } } func show() { @@ -113,7 +117,7 @@ final class SettingsWindowManager: ObservableObject { Log.success("Settings window closed", category: .settingsWindowManager) } - + private func restartTimer() { stopTimer() startTimer() @@ -128,7 +132,7 @@ final class SettingsWindowManager: ObservableObject { if controller?.window?.isKeyWindow == true { setNextPreviewedAction() } - + try await Task.sleep(for: .seconds(1)) } } @@ -138,7 +142,7 @@ final class SettingsWindowManager: ObservableObject { previewActionTimerTask?.cancel() previewActionTimerTask = nil } - + private func setNextPreviewedAction() { if isPreviewingUserSelection { guard let parent = previewedParentAction, @@ -148,23 +152,23 @@ final class SettingsWindowManager: ObservableObject { else { return } - + let nextIndex = (index + 1) % cycle.count setPreviewedAction(to: parent, cycleAction: cycle[nextIndex]) } else { - let radialMenuActions: [WindowAction] = Defaults[.radialMenuActions] + let radialMenuActions: [WindowAction] = RadialMenuWindowAction.userConfiguredActions .map { $0.resolvedAction ?? .init(.noAction) } - + 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 diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift index 574edbc7..939f8dcf 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -29,7 +29,7 @@ struct RadialMenuActionItemView: View { var body: some View { HStack { label - + Spacer() if radialMenuAction.isKeybindReference { @@ -89,7 +89,7 @@ struct RadialMenuActionItemView: View { Image(systemName: "bolt.horizontal.fill") .foregroundStyle(.secondary) - Text("Failed to resolve keybind") + Text("Failed to resolve linked keybind") .foregroundStyle(.secondary) } } @@ -122,11 +122,11 @@ struct RadialMenuActionItemView: View { ) if resolvedAction.direction.isCustomizable { - Button(action: { + Button { isConfiguringCustom = true - }, label: { + } label: { Image(systemName: "slider.horizontal.3") - }) + } .buttonStyle(.plain) .luminareModalWithPredefinedSheetStyle(isPresented: $isConfiguringCustom, isCompact: false) { if resolvedAction.direction == .custom { @@ -141,11 +141,11 @@ struct RadialMenuActionItemView: View { } if resolvedAction.direction == .cycle { - Button(action: { + Button { isConfiguringCycle = true - }, label: { + } label: { Image(systemName: "repeat") - }) + } .buttonStyle(.plain) .luminareModalWithPredefinedSheetStyle(isPresented: $isConfiguringCycle, isCompact: false) { CycleActionConfigurationView(action: actionBinding, isPresented: $isConfiguringCycle) diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift index 4039c09d..8b872502 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift @@ -5,9 +5,9 @@ // Created by Kai Azim on 2026-01-01. // -import SwiftUI -import Luminare import Defaults +import Luminare +import SwiftUI struct RadialMenuActionsGuide: View { @EnvironmentObject private var windowModel: SettingsWindowManager @@ -15,23 +15,23 @@ struct RadialMenuActionsGuide: View { @Environment(\.luminareAnimation) private var luminareAnimation @Default(.radialMenuActions) private var radialMenuActions - + private var radialActions: [RadialMenuWindowAction] { Array(radialMenuActions.dropLast()) } - + private var centerAction: RadialMenuWindowAction { 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) } @@ -75,7 +75,7 @@ struct RadialMenuActionsGuide: View { .frame(width: 200, height: 200) .animation(luminareAnimation, value: radialMenuActions) } - + @ViewBuilder private func actionButton( action: WindowAction? = nil, @@ -92,7 +92,7 @@ struct RadialMenuActionsGuide: View { } else { windowModel.isPreviewingUserSelection = true } - + if windowModel.isPreviewingUserSelection { windowModel.setPreviewedAction(to: action) } diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index 224a4c6a..e4229767 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -99,7 +99,7 @@ struct RadialMenuConfigurationView: View { } } } - + private func userSelectionChanged(_ newValue: Set) { if newValue.count == 1, let resolved = newValue.first?.resolvedAction { windowModel.isPreviewingUserSelection = true @@ -108,14 +108,14 @@ struct RadialMenuConfigurationView: View { windowModel.isPreviewingUserSelection = false } } - - private func previewedActionChanged(_ newValue: WindowAction) { + + private func previewedActionChanged(_: WindowAction) { guard windowModel.isPreviewingUserSelection else { return } let selectedAction = windowModel.previewedParentAction ?? windowModel.previewedAction - + if let match = radialMenuActions.first(where: { $0.id == selectedAction.id }) { selectedRadialMenuActions = [match] } else { diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuIconView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuIconView.swift index 0f1d37aa..c2c2f37f 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuIconView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuIconView.swift @@ -5,38 +5,38 @@ // Created by Kai Azim on 2025-12-08. // -import SwiftUI import Defaults +import SwiftUI struct RadialMenuIconView: View { private static let size: CGFloat = 18.0 private static let normalSize: CGFloat = 100.0 private static var miniScaleFactor: CGFloat { - Self.size / Self.normalSize + size / normalSize } - + @Default(.radialMenuCornerRadius) private var radialMenuCornerRadius @Default(.radialMenuThickness) private var radialMenuThickness - + private var cornerRadius: CGFloat { radialMenuCornerRadius * Self.miniScaleFactor } - + private var thickness: CGFloat { radialMenuThickness * Self.miniScaleFactor + 1 } - + let totalItems: Int let index: Int - + private var shouldFillRadialMenu: Bool { index == totalItems - 1 } - + private var degreesPerDirection: CGFloat { 360.0 / Double(totalItems - 1) } - + var body: some View { Rectangle() .mask { @@ -45,10 +45,10 @@ struct RadialMenuIconView: View { angleIndicator } - .frame(width: 18, height: 18) - .drawingGroup() + .frame(width: 18, height: 18) + .drawingGroup() } - + private var angleIndicator: some View { ZStack { if shouldFillRadialMenu { @@ -76,12 +76,12 @@ struct RadialMenuIconView: View { .foregroundStyle(.white) } } - + private var border: some View { ZStack { RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(lineWidth: 1) - + RoundedRectangle(cornerRadius: cornerRadius) .inset(by: thickness - 1) .strokeBorder(lineWidth: 1) diff --git a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift index 417dfc32..3255b9ec 100644 --- a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift +++ b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift @@ -56,18 +56,16 @@ struct LuminarePreviewView: View { of: windowModel.previewedAction, initial: true ) { newAction in - var newActionRect: CGRect - - if newAction.willManipulateExistingWindowFrame { - newActionRect = .zero + var newActionRect: CGRect = if newAction.willManipulateExistingWindowFrame { + .zero } else { - newActionRect = newAction.getFrame( + newAction.getFrame( window: nil, bounds: .init(origin: .zero, size: geo.size), isPreview: true ) } - + withAnimation(animationConfiguration.previewTimingFunctionSwiftUI) { if newActionRect.size.area == .zero { actionRect = .init( diff --git a/Loop/Window Action Indicators/Radial Menu/RadialLayout.swift b/Loop/Window Action Indicators/Radial Menu/RadialLayout.swift index e157be91..d2099d4a 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialLayout.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialLayout.swift @@ -8,11 +8,11 @@ import SwiftUI struct RadialLayout: Layout { - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + func sizeThatFits(proposal: ProposedViewSize, subviews _: Subviews, cache _: inout ()) -> CGSize { proposal.replacingUnspecifiedDimensions() } - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + 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 diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift index 02f3cb0b..ca37ce99 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -13,7 +13,7 @@ import SwiftUI final class RadialMenuViewModel: ObservableObject { @Published private(set) var angle: Double @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? @@ -36,19 +36,19 @@ final class RadialMenuViewModel: ObservableObject { recomputeAngle() } - + private var effectiveWindowAction: WindowAction { parentAction ?? currentAction } - + private var radialMenuActions: [RadialMenuWindowAction] { - Defaults[.radialMenuActions] + RadialMenuWindowAction.userConfiguredActions } private var directionalRadialMenuActions: [RadialMenuWindowAction] { radialMenuActions.dropLast() } - + private var centerRadialMenuAction: RadialMenuWindowAction? { radialMenuActions.last } @@ -58,11 +58,11 @@ final class RadialMenuViewModel: ObservableObject { if effectiveWindowAction.id == centerRadialMenuAction?.id { return true } - + guard !directionalRadialMenuActions.contains(where: { $0.id == effectiveWindowAction.id }) else { return false } - + // Otherwise, default to the action's settings return effectiveWindowAction.direction.shouldFillRadialMenu } @@ -72,7 +72,7 @@ final class RadialMenuViewModel: ObservableObject { if radialMenuActions.contains(where: { $0.id == effectiveWindowAction.id }) { return false } - + // Otherwise, default to the action's settings return currentAction.direction.hasRadialMenuAngle != true || currentAction.direction.isCustomizable == true } @@ -102,33 +102,33 @@ final class RadialMenuViewModel: ObservableObject { func recomputeAngle() { guard let targetAngle = calculateTargetAngle() else { return } - + let closestAngle = Angle.degrees(angle).angleDifference(to: targetAngle) let shouldAnimate = shouldAnimateTransition(closestAngle: closestAngle) - - withAnimation(shouldAnimate ? AnimationConfiguration.radialMenuAngle : . linear(duration: 0)) { + + withAnimation(shouldAnimate ? AnimationConfiguration.radialMenuAngle : .linear(duration: 0)) { angle += closestAngle.degrees } } - + private func calculateTargetAngle() -> Angle? { // Check directional radial menu actions first if let index = directionalRadialMenuActions.firstIndex(where: { $0.id == 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.id == previousAction.id }) || previousAction.direction.hasRadialMenuAngle } - + return false } } diff --git a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift index 96dc7c62..608625fd 100644 --- a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift +++ b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift @@ -92,3 +92,9 @@ enum RadialMenuWindowAction: Identifiable, Codable, Hashable, Defaults.Serializa ) ] } + +extension RadialMenuWindowAction { + static var userConfiguredActions: [RadialMenuWindowAction] { + Defaults[.enableRadialMenuCustomization] ? Defaults[.radialMenuActions] : defaultRadialMenuActions + } +} diff --git a/Loop/Window Management/Window Action/WindowAction+Image.swift b/Loop/Window Management/Window Action/WindowAction+Image.swift index b5682f57..cae4ce16 100644 --- a/Loop/Window Management/Window Action/WindowAction+Image.swift +++ b/Loop/Window Management/Window Action/WindowAction+Image.swift @@ -276,7 +276,7 @@ final class IconRenderView: NSView { if currentAction.direction == .cycle, let image = NSImage(systemSymbolName: "repeat", accessibilityDescription: nil) { return .image(image) } - + return nil } diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index ef7b35b1..b41be2e8 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -15,7 +15,7 @@ import SwiftUI struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serializable { 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: /// - direction: the direction of the window action. If custom or cycle, use those and further specify the action with the parameters below. @@ -67,7 +67,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial } else { self.id = UUID() } - + self.direction = direction self.keybind = keybind } diff --git a/Loop/Window Management/Window Action/WindowDirection.swift b/Loop/Window Management/Window Action/WindowDirection.swift index 443a3709..d9e27027 100644 --- a/Loop/Window Management/Window Action/WindowDirection.swift +++ b/Loop/Window Management/Window Action/WindowDirection.swift @@ -16,7 +16,7 @@ enum WindowDirection: String, CaseIterable, Identifiable, Codable { /// `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 maximize = "Maximize", almostMaximize = "AlmostMaximize", fullscreen = "Fullscreen" case maximizeHeight = "MaximizeHeight", maximizeWidth = "MaximizeWidth" From 582c5ce872bdf1db6413b6d114d420e620dfedd3 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 2 Jan 2026 17:59:06 -0700 Subject: [PATCH 07/16] =?UTF-8?q?=F0=9F=92=84=20Improve=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings Window/SettingsContentView.swift | 4 +- .../SettingsWindowManager.swift | 2 +- .../RadialMenuActionItemView.swift | 4 +- .../RadialMenuConfigurationView.swift | 73 ++++++++++--------- 4 files changed, 44 insertions(+), 39 deletions(-) diff --git a/Loop/Settings Window/SettingsContentView.swift b/Loop/Settings Window/SettingsContentView.swift index b9610a97..38aaac2c 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 { @@ -58,7 +60,7 @@ struct SettingsContentView: View { RadialMenuView(viewModel: model.radialMenuViewModel) .allowsHitTesting(false) - if model.currentTab == .radialMenu { + if enableRadialMenuCustomization, model.currentTab == .radialMenu { RadialMenuActionsGuide() } } diff --git a/Loop/Settings Window/SettingsWindowManager.swift b/Loop/Settings Window/SettingsWindowManager.swift index f96427f4..51ae2fbb 100644 --- a/Loop/Settings Window/SettingsWindowManager.swift +++ b/Loop/Settings Window/SettingsWindowManager.swift @@ -157,7 +157,7 @@ final class SettingsWindowManager: ObservableObject { setPreviewedAction(to: parent, cycleAction: cycle[nextIndex]) } else { let radialMenuActions: [WindowAction] = RadialMenuWindowAction.userConfiguredActions - .map { $0.resolvedAction ?? .init(.noAction) } + .compactMap(\.resolvedAction) let nextAction = if let index = radialMenuActions.firstIndex(of: previewedParentAction ?? previewedAction) { radialMenuActions[(index + 1) % radialMenuActions.count] diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift index 939f8dcf..95408f69 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -33,7 +33,7 @@ struct RadialMenuActionItemView: View { Spacer() if radialMenuAction.isKeybindReference { - Image(systemName: "link") + Image(systemName: "keyboard") .foregroundStyle(.secondary) } } @@ -236,7 +236,7 @@ struct RadialMenuActionPickerView: View { Spacer() if item.isKeybindReference { - Image(systemName: "link") + Image(systemName: "keyboard") .foregroundStyle(.secondary) } } diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index e4229767..a0728ea0 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -15,6 +15,7 @@ struct RadialMenuConfigurationView: View { @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 = [] @@ -56,46 +57,48 @@ struct RadialMenuConfigurationView: View { } .animation(.smooth(duration: 0.25), value: radialMenuVisibility) - LuminareSection(String(localized: "Actions", comment: "Section header shown in settings")) { - HStack(spacing: 4) { - Button("Add") { - radialMenuActions.insert(.custom(.init(.noAction)), at: 0) - } - .luminareRoundingBehavior(topLeading: true) + if enableRadialMenuCustomization { + LuminareSection(String(localized: "Actions", comment: "Section header 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) + Button("Remove", role: .destructive) { + radialMenuActions.removeAll(where: selectedRadialMenuActions.contains) + } + .luminareRoundingBehavior(topTrailing: true) + .disabled(selectedRadialMenuActions.isEmpty) + .keyboardShortcut(.delete) } - .luminareRoundingBehavior(topTrailing: true) - .disabled(selectedRadialMenuActions.isEmpty) - .keyboardShortcut(.delete) - } - LuminareList( - items: $radialMenuActions, - selection: $selectedRadialMenuActions, - id: \.id - ) { action in - RadialMenuActionItemView(action) - } emptyView: { - HStack { - Spacer() - VStack { - Text("No radial menu actions") - .font(.title3) - Text("Press \"Add\" to add an action") - .font(.caption) + LuminareList( + items: $radialMenuActions, + selection: $selectedRadialMenuActions, + id: \.id + ) { action in + RadialMenuActionItemView(action) + } emptyView: { + HStack { + Spacer() + VStack { + Text("No radial menu actions") + .font(.title3) + Text("Press \"Add\" to add an action") + .font(.caption) + } + Spacer() } - Spacer() + .foregroundStyle(.secondary) + .padding() + } + .luminareRoundingBehavior(bottom: true) + .onChange(of: selectedRadialMenuActions, perform: userSelectionChanged) + .onChange(of: windowModel.previewedParentAction ?? windowModel.previewedAction, perform: previewedActionChanged) + .onDisappear { + windowModel.isPreviewingUserSelection = false } - .foregroundStyle(.secondary) - .padding() - } - .luminareRoundingBehavior(bottom: true) - .onChange(of: selectedRadialMenuActions, perform: userSelectionChanged) - .onChange(of: windowModel.previewedParentAction ?? windowModel.previewedAction, perform: previewedActionChanged) - .onDisappear { - windowModel.isPreviewingUserSelection = false } } } From 142ec782a8890c279db02bcb6a0deffb03d24458 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 2 Jan 2026 22:11:41 -0700 Subject: [PATCH 08/16] =?UTF-8?q?=E2=9C=A8=20Move=20RadialMenuActionPicker?= =?UTF-8?q?View=20into=20new=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RadialMenuActionItemView.swift | 144 ----------------- .../RadialMenuActionPickerView.swift | 153 ++++++++++++++++++ .../Radial Menu/RadialMenuIconView.swift | 91 ----------- 3 files changed, 153 insertions(+), 235 deletions(-) create mode 100644 Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift delete mode 100644 Loop/Settings Window/Theming/Radial Menu/RadialMenuIconView.swift diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift index 95408f69..c7ba5772 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -160,147 +160,3 @@ struct RadialMenuActionItemView: View { } } } - -struct RadialMenuActionPickerView: View { - @Default(.keybinds) private var keybinds - - private let padding: CGFloat = 12 - - @State private var searchText = "" - @State private var searchResults: [RadialMenuWindowAction] = [] - - @Binding private var selection: RadialMenuWindowAction - - private static let directionSections: [PickerSection] = { - let windowDirections = PickerSection.windowDirections - .map { section in - PickerSection( - section.title, - section.items.map { RadialMenuWindowAction.custom(.init($0)) } - ) - } - - return windowDirections - }() - - private var keybindsSection: PickerSection { - PickerSection( - "Your Keybinds", - keybinds.map { RadialMenuWindowAction.keybindReference($0.id) } - ) - } - - private var allSections: [PickerSection] { - Self.directionSections + [keybindsSection] - } - - private var allSectionItems: [RadialMenuWindowAction] { - 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 -> (RadialMenuWindowAction, 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/RadialMenuActionPickerView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift new file mode 100644 index 00000000..026aa8b1 --- /dev/null +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift @@ -0,0 +1,153 @@ +// +// RadialMenuActionPickerView.swift +// Loop +// +// Created by Kai Azim on 2026-01-02. +// + +import SwiftUI +import Defaults + +struct RadialMenuActionPickerView: View { + @Default(.keybinds) private var keybinds + + private let padding: CGFloat = 12 + + @State private var searchText = "" + @State private var searchResults: [RadialMenuWindowAction] = [] + + @Binding private var selection: RadialMenuWindowAction + + private static let directionSections: [PickerSection] = { + let windowDirections = PickerSection.windowDirections + .map { section in + PickerSection( + section.title, + section.items.map { RadialMenuWindowAction.custom(.init($0)) } + ) + } + + return windowDirections + }() + + private var keybindsSection: PickerSection { + PickerSection( + "Your Keybinds", + keybinds.map { RadialMenuWindowAction.keybindReference($0.id) } + ) + } + + private var allSections: [PickerSection] { + Self.directionSections + [keybindsSection] + } + + private var allSectionItems: [RadialMenuWindowAction] { + 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 -> (RadialMenuWindowAction, 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/RadialMenuIconView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuIconView.swift deleted file mode 100644 index c2c2f37f..00000000 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuIconView.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// RadialMenuIconView.swift -// Loop -// -// Created by Kai Azim on 2025-12-08. -// - -import Defaults -import SwiftUI - -struct RadialMenuIconView: View { - private static let size: CGFloat = 18.0 - private static let normalSize: CGFloat = 100.0 - private static var miniScaleFactor: CGFloat { - size / normalSize - } - - @Default(.radialMenuCornerRadius) private var radialMenuCornerRadius - @Default(.radialMenuThickness) private var radialMenuThickness - - private var cornerRadius: CGFloat { - radialMenuCornerRadius * Self.miniScaleFactor - } - - private var thickness: CGFloat { - radialMenuThickness * Self.miniScaleFactor + 1 - } - - let totalItems: Int - let index: Int - - private var shouldFillRadialMenu: Bool { - index == totalItems - 1 - } - - private var degreesPerDirection: CGFloat { - 360.0 / Double(totalItems - 1) - } - - var body: some View { - Rectangle() - .mask { - border - .opacity(0.5) - - angleIndicator - } - .frame(width: 18, height: 18) - .drawingGroup() - } - - private var angleIndicator: some View { - ZStack { - if shouldFillRadialMenu { - Color.white - } else { - if cornerRadius >= 18.0 / 2.0 { - DirectionSelectorCircleSegment( - angle: Double(index) * degreesPerDirection - 90.0, - radialMenuSize: 18 - ) - } else { - DirectionSelectorSquareSegment( - angle: Double(index) * degreesPerDirection - 90.0, - radialMenuCornerRadius: cornerRadius, - radialMenuThickness: thickness - ) - } - } - } - .foregroundStyle(.white) - .mask { - RoundedRectangle(cornerRadius: cornerRadius) - .inset(by: thickness / 2) - .stroke(lineWidth: thickness) - .foregroundStyle(.white) - } - } - - private var border: some View { - ZStack { - RoundedRectangle(cornerRadius: cornerRadius) - .strokeBorder(lineWidth: 1) - - RoundedRectangle(cornerRadius: cornerRadius) - .inset(by: thickness - 1) - .strokeBorder(lineWidth: 1) - } - .foregroundStyle(.white) - } -} From bdb37aabc34993465f58ba868816ae9fc37042f2 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 2 Jan 2026 22:33:09 -0700 Subject: [PATCH 09/16] =?UTF-8?q?=E2=9C=A8=20Move=20action=20up/down=20but?= =?UTF-8?q?tons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Loop/AdvancedConfiguration.swift | 12 +++---- .../Theming/AccentColorConfiguration.swift | 3 +- .../RadialMenuActionItemView.swift | 32 +++++++++++++++++-- .../RadialMenuConfigurationView.swift | 20 +++++++++++- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/Loop/Settings Window/Loop/AdvancedConfiguration.swift b/Loop/Settings Window/Loop/AdvancedConfiguration.swift index 7ef78a7d..f4cf90ab 100644 --- a/Loop/Settings Window/Loop/AdvancedConfiguration.swift +++ b/Loop/Settings Window/Loop/AdvancedConfiguration.swift @@ -118,8 +118,6 @@ final class AdvancedConfigurationModel: ObservableObject { struct AdvancedConfigurationView: View { @EnvironmentObject private var windowModel: SettingsWindowManager - - @Environment(\.luminareTintColor) var tint @Environment(\.luminareAnimation) var luminareAnimation @Environment(\.openURL) private var openURL @@ -230,7 +228,7 @@ struct AdvancedConfigurationView: View { if model.showResetRadialMenuActionsSuccessIndicator { Image(systemName: "checkmark") - .foregroundStyle(tint) + .foregroundStyle(.green) .bold() } } @@ -255,7 +253,7 @@ struct AdvancedConfigurationView: View { if model.showImportKeybindsSuccessIndicator { Image(systemName: "checkmark") - .foregroundStyle(tint) + .foregroundStyle(.green) .bold() } } @@ -268,7 +266,7 @@ struct AdvancedConfigurationView: View { if model.showExportKeybindsSuccessIndicator { Image(systemName: "checkmark") - .foregroundStyle(tint) + .foregroundStyle(.green) .bold() } } @@ -282,7 +280,7 @@ struct AdvancedConfigurationView: View { if model.showResetKeybindsSuccessIndicator { Image(systemName: "checkmark") - .foregroundStyle(tint) + .foregroundStyle(.green) .bold() } } @@ -310,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/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 index c7ba5772..02e59fc1 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -17,17 +17,25 @@ struct RadialMenuActionItemView: View { @Default(.keybinds) private var keybinds @Binding private var radialMenuAction: RadialMenuWindowAction + private let moveUp: () -> Void + private let moveDown: () -> Void @State private var isPickerPresented = false @State private var isConfiguringCustom: Bool = false @State private var isConfiguringCycle: Bool = false - init(_ action: Binding) { + init( + _ action: Binding, + moveUp: @escaping () -> Void, + moveDown: @escaping () -> Void + ) { self._radialMenuAction = action + self.moveUp = moveUp + self.moveDown = moveDown } var body: some View { - HStack { + HStack(spacing: 12) { label Spacer() @@ -36,6 +44,26 @@ struct RadialMenuActionItemView: View { Image(systemName: "keyboard") .foregroundStyle(.secondary) } + + 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 diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index a0728ea0..e2c2980b 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -78,7 +78,11 @@ struct RadialMenuConfigurationView: View { selection: $selectedRadialMenuActions, id: \.id ) { action in - RadialMenuActionItemView(action) + RadialMenuActionItemView( + action, + moveUp: { moveAction(action.wrappedValue, down: false) }, + moveDown: { moveAction(action.wrappedValue, down: true) } + ) } emptyView: { HStack { Spacer() @@ -102,6 +106,20 @@ struct RadialMenuConfigurationView: View { } } } + + private func moveAction(_ action: RadialMenuWindowAction, 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?.resolvedAction { From 01fcf925923d2cc9bbb53100547da4ff24520315 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 2 Jan 2026 22:37:14 -0700 Subject: [PATCH 10/16] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Theming/Radial Menu/RadialMenuActionItemView.swift | 10 +++++----- .../Radial Menu/RadialMenuActionPickerView.swift | 2 +- .../Radial Menu/RadialMenuConfigurationView.swift | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift index 02e59fc1..3a8884c3 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -17,8 +17,8 @@ struct RadialMenuActionItemView: View { @Default(.keybinds) private var keybinds @Binding private var radialMenuAction: RadialMenuWindowAction - private let moveUp: () -> Void - private let moveDown: () -> Void + private let moveUp: () -> () + private let moveDown: () -> () @State private var isPickerPresented = false @State private var isConfiguringCustom: Bool = false @@ -26,8 +26,8 @@ struct RadialMenuActionItemView: View { init( _ action: Binding, - moveUp: @escaping () -> Void, - moveDown: @escaping () -> Void + moveUp: @escaping () -> (), + moveDown: @escaping () -> () ) { self._radialMenuAction = action self.moveUp = moveUp @@ -44,7 +44,7 @@ struct RadialMenuActionItemView: View { Image(systemName: "keyboard") .foregroundStyle(.secondary) } - + HStack(spacing: 8) { Button(action: moveUp) { Image(systemName: "arrow.up") diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift index 026aa8b1..87407840 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift @@ -5,8 +5,8 @@ // Created by Kai Azim on 2026-01-02. // -import SwiftUI import Defaults +import SwiftUI struct RadialMenuActionPickerView: View { @Default(.keybinds) private var keybinds diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index e2c2980b..853e4aa5 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -106,15 +106,15 @@ struct RadialMenuConfigurationView: View { } } } - + private func moveAction(_ action: RadialMenuWindowAction, 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 From 4f0c25b70f8ebb50aefe36398a42ee0d943377c6 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Fri, 2 Jan 2026 22:51:39 -0700 Subject: [PATCH 11/16] =?UTF-8?q?=E2=9C=A8=20Small=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/Observers/Helpers/TriggerDelayTimer.swift | 4 ++-- Loop/Core/Observers/MiddleClickTrigger.swift | 8 ++++---- Loop/Localizable.xcstrings | 5 +++++ Loop/Settings Window/SettingsWindowManager.swift | 4 ++-- .../Radial Menu/RadialMenuActionItemView.swift | 11 ++++++----- .../Radial Menu/RadialMenuActionPickerView.swift | 4 ++-- .../Theming/Radial Menu/RadialMenuActionsGuide.swift | 4 ++-- .../Radial Menu/RadialMenuConfigurationView.swift | 2 +- .../Preview Window/LuminarePreviewView.swift | 2 +- .../Preview Window/PreviewController.swift | 5 +++++ .../Window Action/RadialMenuWindowAction.swift | 2 +- 11 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Loop/Core/Observers/Helpers/TriggerDelayTimer.swift b/Loop/Core/Observers/Helpers/TriggerDelayTimer.swift index 3a52d5fa..3c662bf3 100644 --- a/Loop/Core/Observers/Helpers/TriggerDelayTimer.swift +++ b/Loop/Core/Observers/Helpers/TriggerDelayTimer.swift @@ -16,7 +16,7 @@ import Foundation /// In that case, use the `updateStartingAction` method. final class TriggerDelayTimer { private var triggerDelayTimer: Task<(), Never>? - private var startingAction: WindowAction = .init(.noAction) + private var startingAction: WindowAction = .init(.noSelection) private let openCallback: (WindowAction) -> () private var triggerDelay: CGFloat { Defaults[.triggerDelay] } @@ -61,6 +61,6 @@ final class TriggerDelayTimer { func cancel() { triggerDelayTimer?.cancel() triggerDelayTimer = nil - startingAction = .init(.noAction) + startingAction = .init(.noSelection) } } diff --git a/Loop/Core/Observers/MiddleClickTrigger.swift b/Loop/Core/Observers/MiddleClickTrigger.swift index c10acf02..a185763c 100644 --- a/Loop/Core/Observers/MiddleClickTrigger.swift +++ b/Loop/Core/Observers/MiddleClickTrigger.swift @@ -27,7 +27,7 @@ final class MiddleClickTrigger { guard let self else { return } if useTriggerDelay { - triggerDelayTimer.handleTrigger(startingAction: .init(.noAction)) + triggerDelayTimer.handleTrigger(startingAction: .init(.noSelection)) } else { openCallback(action) } @@ -76,11 +76,11 @@ final class MiddleClickTrigger { if event.type == .otherMouseDown, event.getIntegerValueField(.mouseEventButtonNumber) == 2 { if doubleClickToTrigger { - doubleClickTimer.handleTrigger(startingAction: .init(.noAction)) + doubleClickTimer.handleTrigger(startingAction: .init(.noSelection)) } else if useTriggerDelay { - triggerDelayTimer.handleTrigger(startingAction: .init(.noAction)) + triggerDelayTimer.handleTrigger(startingAction: .init(.noSelection)) } else { - openCallback(.init(.noAction)) + openCallback(.init(.noSelection)) } } else { triggerDelayTimer.cancel() diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index f97d8a7b..5a470c0e 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -3812,6 +3812,7 @@ } }, "Customize this keybind's custom frame." : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -26741,6 +26742,10 @@ } } }, + "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 diff --git a/Loop/Settings Window/SettingsWindowManager.swift b/Loop/Settings Window/SettingsWindowManager.swift index 51ae2fbb..5fec2a45 100644 --- a/Loop/Settings Window/SettingsWindowManager.swift +++ b/Loop/Settings Window/SettingsWindowManager.swift @@ -65,7 +65,7 @@ final class SettingsWindowManager: ObservableObject { self.radialMenuViewModel = .init(startingAction: startingAction, window: nil, previewMode: true) - if let firstAction = RadialMenuWindowAction.userConfiguredActions.first?.resolvedAction { + if let firstAction = RadialMenuWindowAction.userConfiguredActions.first?.resolved { setPreviewedAction(to: firstAction) } } @@ -157,7 +157,7 @@ final class SettingsWindowManager: ObservableObject { setPreviewedAction(to: parent, cycleAction: cycle[nextIndex]) } else { let radialMenuActions: [WindowAction] = RadialMenuWindowAction.userConfiguredActions - .compactMap(\.resolvedAction) + .compactMap(\.resolved) let nextAction = if let index = radialMenuActions.firstIndex(of: previewedParentAction ?? previewedAction) { radialMenuActions[(index + 1) % radialMenuActions.count] diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift index 3a8884c3..a01f60e8 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -43,6 +43,7 @@ struct RadialMenuActionItemView: View { if radialMenuAction.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) { @@ -71,8 +72,8 @@ struct RadialMenuActionItemView: View { isPickerPresented = false } } - .onChange(of: radialMenuAction.resolvedAction) { _ in - if let resolvedAction = radialMenuAction.resolvedAction { + .onChange(of: radialMenuAction.resolved) { _ in + if let resolvedAction = radialMenuAction.resolved { if resolvedAction.direction.isCustomizable { isConfiguringCustom = true } @@ -107,7 +108,7 @@ struct RadialMenuActionItemView: View { isPickerPresented = true } label: { HStack(spacing: 8) { - if let action = radialMenuAction.resolvedAction { + if let action = radialMenuAction.resolved { IconView(action: action) Text(action.getName()) @@ -131,7 +132,7 @@ struct RadialMenuActionItemView: View { .padding(.leading, -4) Group { - if let resolvedAction = radialMenuAction.resolvedAction { + if let resolvedAction = radialMenuAction.resolved { let actionBinding = Binding( get: { resolvedAction @@ -165,7 +166,7 @@ struct RadialMenuActionItemView: View { .frame(width: 400) } } - .help("Customize this keybind's custom frame.") + .help("Customize this action's custom frame.") } if resolvedAction.direction == .cycle { diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift index 87407840..2911977f 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift @@ -68,7 +68,7 @@ struct RadialMenuActionPickerView: View { allSections ) { item in HStack(spacing: 8) { - if let action = item.resolvedAction { + if let action = item.resolved { HStack(spacing: 8) { IconView(action: action) @@ -112,7 +112,7 @@ struct RadialMenuActionPickerView: View { let matches = allSectionItems .compactMap { item -> (RadialMenuWindowAction, Int)? in - guard let action = item.resolvedAction else { return nil } + guard let action = item.resolved else { return nil } if let score = fuzzyScore(action.getName(), key) { return (item, score) diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift index 8b872502..64c3b231 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift @@ -38,7 +38,7 @@ struct RadialMenuActionsGuide: View { var body: some View { ZStack { - if let centerResolved = centerAction.resolvedAction { + if let centerResolved = centerAction.resolved { actionButton( action: centerResolved, isActive: centerResolved == activeAction @@ -54,7 +54,7 @@ struct RadialMenuActionsGuide: View { RadialLayout { ForEach(Array(radialMenuActions.dropLast()), id: \.id) { action in - if let resolved = action.resolvedAction { + if let resolved = action.resolved { actionButton( action: resolved, isActive: resolved == activeAction diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index 853e4aa5..b41ea254 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -122,7 +122,7 @@ struct RadialMenuConfigurationView: View { } private func userSelectionChanged(_ newValue: Set) { - if newValue.count == 1, let resolved = newValue.first?.resolvedAction { + if newValue.count == 1, let resolved = newValue.first?.resolved { windowModel.isPreviewingUserSelection = true windowModel.setPreviewedAction(to: resolved) } else { diff --git a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift index 3255b9ec..698aa6c9 100644 --- a/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift +++ b/Loop/Window Action Indicators/Preview Window/LuminarePreviewView.swift @@ -56,7 +56,7 @@ struct LuminarePreviewView: View { of: windowModel.previewedAction, initial: true ) { newAction in - var newActionRect: CGRect = if newAction.willManipulateExistingWindowFrame { + let newActionRect: CGRect = if newAction.willManipulateExistingWindowFrame { .zero } else { newAction.getFrame( 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 Management/Window Action/RadialMenuWindowAction.swift b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift index 608625fd..d747d450 100644 --- a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift +++ b/Loop/Window Management/Window Action/RadialMenuWindowAction.swift @@ -21,7 +21,7 @@ enum RadialMenuWindowAction: Identifiable, Codable, Hashable, Defaults.Serializa } } - var resolvedAction: WindowAction? { + var resolved: WindowAction? { switch self { case let .custom(windowAction): windowAction From 1f42c58ddf893d08b2333a2212e75650460965e9 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 3 Jan 2026 14:09:38 -0700 Subject: [PATCH 12/16] =?UTF-8?q?=F0=9F=92=84=20Keep=20stable=20ID=20for?= =?UTF-8?q?=20radial=20menu=20actions=20inside=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Observers/MouseInteractionObserver.swift | 8 +- Loop/Extensions/Defaults+Extensions.swift | 5 +- Loop/Localizable.xcstrings | 22 +-- .../CustomActionConfigurationView.swift | 10 +- .../CycleActionConfigurationView.swift | 11 +- .../SettingsWindowManager.swift | 4 +- .../RadialMenuActionItemView.swift | 103 ++++++++++----- .../RadialMenuActionPickerView.swift | 31 +++-- .../Radial Menu/RadialMenuActionsGuide.swift | 4 +- .../RadialMenuConfigurationView.swift | 8 +- .../Radial Menu/RadialMenuViewModel.swift | 18 +-- .../Window Action/RadialMenuAction.swift | 125 ++++++++++++++++++ .../RadialMenuWindowAction.swift | 100 -------------- .../Window Action/WindowAction.swift | 2 +- 14 files changed, 263 insertions(+), 188 deletions(-) create mode 100644 Loop/Window Management/Window Action/RadialMenuAction.swift delete mode 100644 Loop/Window Management/Window Action/RadialMenuWindowAction.swift diff --git a/Loop/Core/Observers/MouseInteractionObserver.swift b/Loop/Core/Observers/MouseInteractionObserver.swift index aff9ff79..d85b4077 100644 --- a/Loop/Core/Observers/MouseInteractionObserver.swift +++ b/Loop/Core/Observers/MouseInteractionObserver.swift @@ -23,8 +23,8 @@ final class MouseInteractionObserver { private var previousAngleToMouse: Angle = .zero private var previousDistanceToMouse: CGFloat = .zero - private var radialMenuActions: [RadialMenuWindowAction] { - RadialMenuWindowAction.userConfiguredActions + private var radialMenuActions: [RadialMenuAction] { + RadialMenuAction.userConfiguredActions } private static let failedToResolveKeybindAction: WindowAction = .init(.noAction) // This helps to keep a stable ID @@ -103,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] { @@ -122,7 +122,7 @@ final class MouseInteractionObserver { } Task { @MainActor in - switch newAction { + switch newAction?.type { case let .custom(windowAction): changeAction(windowAction) case let .keybindReference(id): diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index fef7002d..fad22a04 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -117,10 +117,7 @@ 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) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 5a470c0e..8fa39927 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -2317,7 +2317,7 @@ } }, "Cancel" : { - "comment" : "The label of a button that cancels an action.", + "comment" : "The \"Cancel\" and \"Reset\" buttons in the reset radial menu actions alert.", "isCommentAutoGenerated" : true }, "Center" : { @@ -3561,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" : { @@ -3644,6 +3648,7 @@ } }, "Custom Keybind" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3726,7 +3731,7 @@ } }, "Customize this action's custom frame." : { - "comment" : "A help text for the button that allows users to customize the custom frame of an action.", + "comment" : "A help text describing the customization option for a custom-direction action.", "isCommentAutoGenerated" : true }, "Customize this keybind's action." : { @@ -3895,7 +3900,7 @@ } }, "Customize what this action cycles through." : { - "comment" : "A tooltip explaining that you can customize what an 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." : { @@ -5383,7 +5388,7 @@ } }, "Failed to resolve linked keybind" : { - "comment" : "A message displayed when a linked keybind in a radial menu action cannot be resolved.", + "comment" : "An error message displayed when a linked keybind cannot be resolved.", "isCommentAutoGenerated" : true }, "First Fourth" : { @@ -15612,7 +15617,7 @@ } }, "No radial menu actions" : { - "comment" : "A message displayed when there are no radial menu actions configured.", + "comment" : "A message shown when there are no actions configured for the radial menu.", "isCommentAutoGenerated" : true }, "No updates available message 01" : { @@ -20457,7 +20462,7 @@ } }, "Press \"Add\" to add an action" : { - "comment" : "A description displayed when there are no radial menu actions. It instructs the user to add actions.", + "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" : { @@ -21625,7 +21630,8 @@ "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?" : { @@ -26751,7 +26757,7 @@ "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 clear all custom actions.", + "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." : { 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/SettingsWindowManager.swift b/Loop/Settings Window/SettingsWindowManager.swift index 5fec2a45..8e7ffa23 100644 --- a/Loop/Settings Window/SettingsWindowManager.swift +++ b/Loop/Settings Window/SettingsWindowManager.swift @@ -65,7 +65,7 @@ final class SettingsWindowManager: ObservableObject { self.radialMenuViewModel = .init(startingAction: startingAction, window: nil, previewMode: true) - if let firstAction = RadialMenuWindowAction.userConfiguredActions.first?.resolved { + if let firstAction = RadialMenuAction.userConfiguredActions.first?.resolved { setPreviewedAction(to: firstAction) } } @@ -156,7 +156,7 @@ final class SettingsWindowManager: ObservableObject { let nextIndex = (index + 1) % cycle.count setPreviewedAction(to: parent, cycleAction: cycle[nextIndex]) } else { - let radialMenuActions: [WindowAction] = RadialMenuWindowAction.userConfiguredActions + let radialMenuActions: [WindowAction] = RadialMenuAction.userConfiguredActions .compactMap(\.resolved) let nextAction = if let index = radialMenuActions.firstIndex(of: previewedParentAction ?? previewedAction) { diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift index a01f60e8..7a416e3c 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionItemView.swift @@ -9,27 +9,58 @@ 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 - @Binding private var radialMenuAction: RadialMenuWindowAction private let moveUp: () -> () private let moveDown: () -> () @State private var isPickerPresented = false - @State private var isConfiguringCustom: Bool = false - @State private var isConfiguringCycle: Bool = false init( - _ action: Binding, + _ action: Binding, moveUp: @escaping () -> (), moveDown: @escaping () -> () ) { - self._radialMenuAction = action + self._wrapper = StateObject(wrappedValue: RadialMenuWindowActionWrapper(binding: action)) self.moveUp = moveUp self.moveDown = moveDown } @@ -40,7 +71,7 @@ struct RadialMenuActionItemView: View { Spacer() - if radialMenuAction.isKeybindReference { + 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.") @@ -72,16 +103,6 @@ struct RadialMenuActionItemView: View { isPickerPresented = false } } - .onChange(of: radialMenuAction.resolved) { _ in - if let resolvedAction = radialMenuAction.resolved { - if resolvedAction.direction.isCustomizable { - isConfiguringCustom = true - } - if resolvedAction.direction == .cycle { - isConfiguringCycle = true - } - } - } } @ViewBuilder @@ -94,7 +115,7 @@ struct RadialMenuActionItemView: View { isPresented: $isPickerPresented, alignment: .leadingLastTextBaseline ) { - RadialMenuActionPickerView(selection: $radialMenuAction) + RadialMenuActionPickerView(selection: $wrapper.action.type) } .luminareSheetClosesOnDefocus(true) } @@ -108,7 +129,7 @@ struct RadialMenuActionItemView: View { isPickerPresented = true } label: { HStack(spacing: 8) { - if let action = radialMenuAction.resolved { + if let action = wrapper.action.resolved { IconView(action: action) Text(action.getName()) @@ -132,38 +153,48 @@ struct RadialMenuActionItemView: View { .padding(.leading, -4) Group { - if let resolvedAction = radialMenuAction.resolved { + if let resolvedAction = wrapper.action.resolved { let actionBinding = Binding( get: { resolvedAction }, set: { newAction in - if radialMenuAction.isKeybindReference { - guard let index = radialMenuAction.keybindIndex else { + 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 - } else { - radialMenuAction = .custom(newAction) } } ) if resolvedAction.direction.isCustomizable { Button { - isConfiguringCustom = true + wrapper.isConfiguringCustom = true } label: { Image(systemName: "slider.horizontal.3") } .buttonStyle(.plain) - .luminareModalWithPredefinedSheetStyle(isPresented: $isConfiguringCustom, isCompact: false) { + .luminareModalWithPredefinedSheetStyle( + isPresented: $wrapper.isConfiguringCustom, + isCompact: false + ) { if resolvedAction.direction == .custom { - CustomActionConfigurationView(action: actionBinding, isPresented: $isConfiguringCustom) - .frame(width: 400) + CustomActionConfigurationView( + action: actionBinding, + isPresented: $wrapper.isConfiguringCustom + ) + .frame(width: 400) } else { - StashActionConfigurationView(action: actionBinding, isPresented: $isConfiguringCustom) - .frame(width: 400) + StashActionConfigurationView( + action: actionBinding, + isPresented: $wrapper.isConfiguringCustom + ) + .frame(width: 400) } } .help("Customize this action's custom frame.") @@ -171,14 +202,20 @@ struct RadialMenuActionItemView: View { if resolvedAction.direction == .cycle { Button { - isConfiguringCycle = true + wrapper.isConfiguringCycle = true } label: { Image(systemName: "repeat") } .buttonStyle(.plain) - .luminareModalWithPredefinedSheetStyle(isPresented: $isConfiguringCycle, isCompact: false) { - CycleActionConfigurationView(action: actionBinding, isPresented: $isConfiguringCycle) - .frame(width: 400) + .luminareModalWithPredefinedSheetStyle( + isPresented: $wrapper.isConfiguringCycle, + isCompact: false + ) { + CycleActionConfigurationView( + action: actionBinding, + isPresented: $wrapper.isConfiguringCycle + ) + .frame(width: 400) } .help("Customize what this action cycles through.") } diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift index 2911977f..72a42f84 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionPickerView.swift @@ -14,40 +14,45 @@ struct RadialMenuActionPickerView: View { private let padding: CGFloat = 12 @State private var searchText = "" - @State private var searchResults: [RadialMenuWindowAction] = [] + @State private var searchResults: [RadialMenuAction.ActionType] = [] - @Binding private var selection: RadialMenuWindowAction + @Binding private var selection: RadialMenuAction.ActionType - private static let directionSections: [PickerSection] = { + private static let directionSections: [PickerSection] = { let windowDirections = PickerSection.windowDirections .map { section in PickerSection( section.title, - section.items.map { RadialMenuWindowAction.custom(.init($0)) } + section.items.map { RadialMenuAction.ActionType.custom(.init($0)) } ) } - return windowDirections + 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 { + private var keybindsSection: PickerSection { PickerSection( "Your Keybinds", - keybinds.map { RadialMenuWindowAction.keybindReference($0.id) } + keybinds.map { RadialMenuAction.ActionType.keybindReference($0.id) } ) } - private var allSections: [PickerSection] { + private var allSections: [PickerSection] { Self.directionSections + [keybindsSection] } - private var allSectionItems: [RadialMenuWindowAction] { + private var allSectionItems: [RadialMenuAction.ActionType] { allSections .map(\.items) .flatMap(\.self) } - init(selection: Binding) { + init(selection: Binding) { self._selection = selection } @@ -68,7 +73,7 @@ struct RadialMenuActionPickerView: View { allSections ) { item in HStack(spacing: 8) { - if let action = item.resolved { + if let action = item.resolvedAction { HStack(spacing: 8) { IconView(action: action) @@ -111,8 +116,8 @@ struct RadialMenuActionPickerView: View { let key = searchText.lowercased() let matches = allSectionItems - .compactMap { item -> (RadialMenuWindowAction, Int)? in - guard let action = item.resolved else { return nil } + .compactMap { item -> (RadialMenuAction.ActionType, Int)? in + guard let action = item.resolvedAction else { return nil } if let score = fuzzyScore(action.getName(), key) { return (item, score) diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift index 64c3b231..835a9f19 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuActionsGuide.swift @@ -16,11 +16,11 @@ struct RadialMenuActionsGuide: View { @Default(.radialMenuActions) private var radialMenuActions - private var radialActions: [RadialMenuWindowAction] { + private var radialActions: [RadialMenuAction] { Array(radialMenuActions.dropLast()) } - private var centerAction: RadialMenuWindowAction { + private var centerAction: RadialMenuAction { radialMenuActions.last ?? .custom(.init(.noAction)) } diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index b41ea254..ae71859f 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -17,7 +17,7 @@ struct RadialMenuConfigurationView: View { @Default(.radialMenuThickness) private var radialMenuThickness @Default(.enableRadialMenuCustomization) var enableRadialMenuCustomization @Default(.radialMenuActions) private var radialMenuActions - @State private var selectedRadialMenuActions: Set = [] + @State private var selectedRadialMenuActions: Set = [] var body: some View { LuminareSection { @@ -107,7 +107,7 @@ struct RadialMenuConfigurationView: View { } } - private func moveAction(_ action: RadialMenuWindowAction, down: Bool) { + private func moveAction(_ action: RadialMenuAction, down: Bool) { guard let index = radialMenuActions.firstIndex(where: { $0.id == action.id }) else { return } @@ -121,7 +121,7 @@ struct RadialMenuConfigurationView: View { ) } - private func userSelectionChanged(_ newValue: Set) { + private func userSelectionChanged(_ newValue: Set) { if newValue.count == 1, let resolved = newValue.first?.resolved { windowModel.isPreviewingUserSelection = true windowModel.setPreviewedAction(to: resolved) @@ -137,7 +137,7 @@ struct RadialMenuConfigurationView: View { let selectedAction = windowModel.previewedParentAction ?? windowModel.previewedAction - if let match = radialMenuActions.first(where: { $0.id == selectedAction.id }) { + if let match = radialMenuActions.first(where: { $0.associatedActionId == selectedAction.id }) { selectedRadialMenuActions = [match] } else { selectedRadialMenuActions = [] diff --git a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift index ca37ce99..a2c8396d 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuViewModel.swift @@ -41,25 +41,25 @@ final class RadialMenuViewModel: ObservableObject { parentAction ?? currentAction } - private var radialMenuActions: [RadialMenuWindowAction] { - RadialMenuWindowAction.userConfiguredActions + private var radialMenuActions: [RadialMenuAction] { + RadialMenuAction.userConfiguredActions } - private var directionalRadialMenuActions: [RadialMenuWindowAction] { + private var directionalRadialMenuActions: [RadialMenuAction] { radialMenuActions.dropLast() } - private var centerRadialMenuAction: RadialMenuWindowAction? { + private var centerRadialMenuAction: RadialMenuAction? { radialMenuActions.last } var shouldFillRadialMenu: Bool { // If the user has the center action selected, then fill the radial menu - if effectiveWindowAction.id == centerRadialMenuAction?.id { + if effectiveWindowAction.id == centerRadialMenuAction?.associatedActionId { return true } - guard !directionalRadialMenuActions.contains(where: { $0.id == effectiveWindowAction.id }) else { + guard !directionalRadialMenuActions.contains(where: { $0.associatedActionId == effectiveWindowAction.id }) else { return false } @@ -69,7 +69,7 @@ final class RadialMenuViewModel: ObservableObject { var shouldHideDirectionSelector: Bool { // If the current action is a user-set radial menu action, always show the direction selector - if radialMenuActions.contains(where: { $0.id == effectiveWindowAction.id }) { + if radialMenuActions.contains(where: { $0.associatedActionId == effectiveWindowAction.id }) { return false } @@ -113,7 +113,7 @@ final class RadialMenuViewModel: ObservableObject { private func calculateTargetAngle() -> Angle? { // Check directional radial menu actions first - if let index = directionalRadialMenuActions.firstIndex(where: { $0.id == effectiveWindowAction.id }) { + if let index = directionalRadialMenuActions.firstIndex(where: { $0.associatedActionId == effectiveWindowAction.id }) { let actionAngleSpan = 360.0 / CGFloat(directionalRadialMenuActions.count) return Angle(degrees: CGFloat(index) * actionAngleSpan - 90) } @@ -126,7 +126,7 @@ final class RadialMenuViewModel: ObservableObject { guard abs(closestAngle.degrees) < 179 else { return false } if let previousAction { - return directionalRadialMenuActions.contains(where: { $0.id == previousAction.id }) || previousAction.direction.hasRadialMenuAngle + 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 d747d450..00000000 --- a/Loop/Window Management/Window Action/RadialMenuWindowAction.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// RadialMenuWindowAction.swift -// Loop -// -// Created by Kai Azim on 2025-11-11. -// - -import Defaults -import Foundation - -enum RadialMenuWindowAction: Identifiable, Codable, Hashable, Defaults.Serializable { - case custom(WindowAction) - case keybindReference(UUID) - - var id: UUID { - switch self { - case let .custom(windowAction): - windowAction.id - case let .keybindReference(id): - id - } - } - - var resolved: 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 - } - } - - var keybindIndex: Int? { - switch self { - case .custom: - nil - case let .keybindReference(id): - Defaults[.keybinds].firstIndex { $0.id == id } - } - } - - static let defaultRadialMenuActions: [RadialMenuWindowAction] = [ - .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) - ] - ) - ) - ] -} - -extension RadialMenuWindowAction { - static var userConfiguredActions: [RadialMenuWindowAction] { - Defaults[.enableRadialMenuCustomization] ? Defaults[.radialMenuActions] : defaultRadialMenuActions - } -} diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index b41be2e8..d8df92dd 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -120,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 { From 0fb44474c3a9d49421d24838472493f027e399db Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 3 Jan 2026 14:18:13 -0700 Subject: [PATCH 13/16] =?UTF-8?q?=F0=9F=90=9E=20Fix=20larger/smaller=20act?= =?UTF-8?q?ions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Window Action/WindowAction.swift | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index d8df92dd..fefd40a1 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -591,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 @@ -603,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 From 12711b0a0dedde566408360e5aaa41960160971f Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 3 Jan 2026 14:25:48 -0700 Subject: [PATCH 14/16] =?UTF-8?q?=E2=9C=A8=20Add=20left=20click=20info=20t?= =?UTF-8?q?o=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Localizable.xcstrings | 3 +++ .../Theming/Radial Menu/RadialMenuConfigurationView.swift | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 8fa39927..09f13499 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -11251,6 +11251,9 @@ } } }, + "Left-click to step through cycle actions." : { + "comment" : "Section footer shown in settings" + }, "Locked icon alert title" : { "localizations" : { "ar" : { diff --git a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index ae71859f..1f76c42f 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -58,7 +58,10 @@ struct RadialMenuConfigurationView: View { .animation(.smooth(duration: 0.25), value: radialMenuVisibility) if enableRadialMenuCustomization { - LuminareSection(String(localized: "Actions", comment: "Section header shown in settings")) { + 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) From e6fe2e059794972ffde5602419206d2d403dd382 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 3 Jan 2026 15:01:20 -0700 Subject: [PATCH 15/16] =?UTF-8?q?=F0=9F=90=9E=20General=20bug=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/App/DataPatcher.swift | 32 +++++--- Loop/Extensions/Defaults+Extensions.swift | 6 +- .../RadialMenuConfigurationView.swift | 6 +- Loop/Stashing/StashedWindowStore.swift | 13 +--- .../Radial Menu/RadialMenuView.swift | 32 ++++---- .../Window Manipulation/WindowEngine.swift | 74 +++++++++---------- 6 files changed, 82 insertions(+), 81 deletions(-) diff --git a/Loop/App/DataPatcher.swift b/Loop/App/DataPatcher.swift index 4b73c6d8..5bd780b5 100644 --- a/Loop/App/DataPatcher.swift +++ b/Loop/App/DataPatcher.swift @@ -11,14 +11,14 @@ import Scribe enum DataPatcher { static func run() { - let initialPatches = Defaults[.patchesApplied] - - if !initialPatches.contains(.accentColorMode) { + let initialPatches: Patches = Defaults[.patchesApplied] + + 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] let processWallpaper: Bool = Defaults[.processWallpaper] - + if useSystemAccentColor { Defaults[.accentColorMode] = .system } else if processWallpaper { @@ -26,15 +26,29 @@ enum DataPatcher { } else { 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/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index fad22a04..274a3570 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -123,8 +123,10 @@ extension Defaults.Keys { 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) @@ -137,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/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift index 1f76c42f..a556872c 100644 --- a/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift +++ b/Loop/Settings Window/Theming/Radial Menu/RadialMenuConfigurationView.swift @@ -133,14 +133,12 @@ struct RadialMenuConfigurationView: View { } } - private func previewedActionChanged(_: WindowAction) { + private func previewedActionChanged(_ newAction: WindowAction) { guard windowModel.isPreviewingUserSelection else { return } - let selectedAction = windowModel.previewedParentAction ?? windowModel.previewedAction - - if let match = radialMenuActions.first(where: { $0.associatedActionId == selectedAction.id }) { + if let match = radialMenuActions.first(where: { $0.associatedActionId == newAction.id }) { selectedRadialMenuActions = [match] } else { selectedRadialMenuActions = [] diff --git a/Loop/Stashing/StashedWindowStore.swift b/Loop/Stashing/StashedWindowStore.swift index 9c156282..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() } @@ -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/Window Action Indicators/Radial Menu/RadialMenuView.swift b/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift index 74bb166c..8984bbce 100644 --- a/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift +++ b/Loop/Window Action Indicators/Radial Menu/RadialMenuView.swift @@ -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 Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index 1a9c737b..2c615ecc 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -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) + + // 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) + // Move cursor to center of window if user has enabled it + if Defaults[.moveCursorWithWindow] { + CGWarpMouseCursorPosition(targetFrame.center) + } } StashManager.shared.onWindowResized( From 28280beab0a9db67b2c1d42e03d9fe75e4e60c60 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 3 Jan 2026 15:02:34 -0700 Subject: [PATCH 16/16] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/App/DataPatcher.swift | 12 ++++++------ Loop/Extensions/Defaults+Extensions.swift | 2 +- .../Window Manipulation/WindowEngine.swift | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Loop/App/DataPatcher.swift b/Loop/App/DataPatcher.swift index 5bd780b5..5fb6f1bd 100644 --- a/Loop/App/DataPatcher.swift +++ b/Loop/App/DataPatcher.swift @@ -12,13 +12,13 @@ import Scribe enum DataPatcher { static func run() { let initialPatches: Patches = Defaults[.patchesApplied] - + 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] let processWallpaper: Bool = Defaults[.processWallpaper] - + if useSystemAccentColor { Defaults[.accentColorMode] = .system } else if processWallpaper { @@ -26,20 +26,20 @@ enum DataPatcher { } else { Defaults[.accentColorMode] = .custom } - + 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) } diff --git a/Loop/Extensions/Defaults+Extensions.swift b/Loop/Extensions/Defaults+Extensions.swift index 274a3570..e9a48a9d 100644 --- a/Loop/Extensions/Defaults+Extensions.swift +++ b/Loop/Extensions/Defaults+Extensions.swift @@ -124,7 +124,7 @@ extension Defaults.Keys { // StashManager static let stashManagerStashedWindows = Key<[CGWindowID: WindowAction]>("stashManagerStashed", default: [:]) - + @available(*, deprecated, message: "Revealed stash windows are no longer tracked.") static let stashManagerRevealedWindows = Key>("stashManagerRevealed", default: Set()) diff --git a/Loop/Window Management/Window Manipulation/WindowEngine.swift b/Loop/Window Management/Window Manipulation/WindowEngine.swift index 2c615ecc..a6176e30 100644 --- a/Loop/Window Management/Window Manipulation/WindowEngine.swift +++ b/Loop/Window Management/Window Manipulation/WindowEngine.swift @@ -77,7 +77,7 @@ enum WindowEngine { } else { // 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, @@ -85,12 +85,12 @@ enum WindowEngine { 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)