diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2b125de2..47b5dd8f 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 0A6DC3EB2BB869DE002AB05F /* WindowAction+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A6DC3EA2BB869DE002AB05F /* WindowAction+Image.swift */; }; 0AFE802E2BB98E81009CF06F /* WindowDirection+LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFE802D2BB98E81009CF06F /* WindowDirection+LocalizedString.swift */; }; + 4C0F2ACF2C3CFD09006CB34D /* RectangleTranslationLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0F2ACE2C3CFD09006CB34D /* RectangleTranslationLayer.swift */; }; + 4C0F2AD42C3D211F006CB34D /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = 4C0F2AD32C3D211F006CB34D /* Luminare */; }; 4C6B93E72C1DCF6E00AFF832 /* TheLoopTimes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6B93E12C1DCF6E00AFF832 /* TheLoopTimes.swift */; }; 4C6B93E82C1DCF6E00AFF832 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6B93E22C1DCF6E00AFF832 /* Updater.swift */; }; 4C6B93E92C1DCF6E00AFF832 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6B93E32C1DCF6E00AFF832 /* UpdateView.swift */; }; @@ -55,7 +57,6 @@ A87DDD152B50A6A400A32C76 /* ScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A87DDD142B50A6A400A32C76 /* ScreenManager.swift */; }; A87F78942BAE28050087B1DE /* CustomWindowActionSizeMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A87F78932BAE28050087B1DE /* CustomWindowActionSizeMode.swift */; }; A87F78962BAE333C0087B1DE /* CustomWindowActionPositionMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A87F78952BAE333C0087B1DE /* CustomWindowActionPositionMode.swift */; }; - A881FC2A2C38A67C00853711 /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = A881FC292C38A67C00853711 /* Luminare */; }; A883642F298B7288005D6C19 /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A883642E298B7288005D6C19 /* ServiceManagement.framework */; }; A8878A252AA3B2C800850A66 /* WindowTransformAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8878A242AA3B2C800850A66 /* WindowTransformAnimation.swift */; }; A88E27AD2BDDE5300042CF04 /* CustomActionConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E27AC2BDDE5300042CF04 /* CustomActionConfigurationView.swift */; }; @@ -87,12 +88,12 @@ A8F0125B2AEDD7660017307F /* WindowAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8F0125A2AEDD7660017307F /* WindowAction.swift */; }; A8F1E9662C253F5B00AAF871 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A8F1E9652C253F5B00AAF871 /* Localizable.xcstrings */; }; A8F1E9692C253F8D00AAF871 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A8F1E9672C253F8D00AAF871 /* InfoPlist.strings */; }; - A8FF7A942C373B4500AF106E /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = A8FF7A932C373B4500AF106E /* Luminare */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 0A6DC3EA2BB869DE002AB05F /* WindowAction+Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowAction+Image.swift"; sourceTree = ""; }; 0AFE802D2BB98E81009CF06F /* WindowDirection+LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowDirection+LocalizedString.swift"; sourceTree = ""; }; + 4C0F2ACE2C3CFD09006CB34D /* RectangleTranslationLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RectangleTranslationLayer.swift; sourceTree = ""; }; 4C6B93E12C1DCF6E00AFF832 /* TheLoopTimes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TheLoopTimes.swift; sourceTree = ""; }; 4C6B93E22C1DCF6E00AFF832 /* Updater.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = ""; }; 4C6B93E32C1DCF6E00AFF832 /* UpdateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = ""; }; @@ -187,8 +188,7 @@ buildActionMask = 2147483647; files = ( A8DCC97B2980D5F500D41065 /* Defaults in Frameworks */, - A881FC2A2C38A67C00853711 /* Luminare in Frameworks */, - A8FF7A942C373B4500AF106E /* Luminare in Frameworks */, + 4C0F2AD42C3D211F006CB34D /* Luminare in Frameworks */, A883642F298B7288005D6C19 /* ServiceManagement.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -210,6 +210,7 @@ A80900D12AA3F9F20085C63B /* Utilities */ = { isa = PBXGroup; children = ( + 4C0F2ACE2C3CFD09006CB34D /* RectangleTranslationLayer.swift */, A8D4327A2C13ED3C007BE4F2 /* Icon.swift */, A82DDBDD2AEC736300D7F974 /* AnimationConfiguration.swift */, A80900D32AA3F9F20085C63B /* VisualEffectView.swift */, @@ -467,8 +468,7 @@ name = Loop; packageProductDependencies = ( A8DCC97A2980D5F500D41065 /* Defaults */, - A8FF7A932C373B4500AF106E /* Luminare */, - A881FC292C38A67C00853711 /* Luminare */, + 4C0F2AD32C3D211F006CB34D /* Luminare */, ); productName = WindowManager; productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; @@ -506,7 +506,7 @@ mainGroup = A8E59C2C297F5E9A0064D4BA; packageReferences = ( A8DCC9792980D5F500D41065 /* XCRemoteSwiftPackageReference "Defaults" */, - A881FC282C38A67C00853711 /* XCRemoteSwiftPackageReference "Luminare" */, + 4C0F2AD22C3D211F006CB34D /* XCRemoteSwiftPackageReference "Luminare" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; @@ -571,6 +571,7 @@ A8789F6729805B190040512E /* RadialMenuView.swift in Sources */, A8330ABD2A3AC0CA00673C8D /* Bundle+Extensions.swift in Sources */, A80D49BB2BAE479900493B67 /* WindowAction+Port.swift in Sources */, + 4C0F2ACF2C3CFD09006CB34D /* RectangleTranslationLayer.swift in Sources */, A8D4327B2C13ED3C007BE4F2 /* Icon.swift in Sources */, A86B97AD2AB79E2500099D7F /* ShakeEffect.swift in Sources */, A8D6D3032B6C8D750061B11F /* PaddingPreviewView.swift in Sources */, @@ -868,7 +869,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - A881FC282C38A67C00853711 /* XCRemoteSwiftPackageReference "Luminare" */ = { + 4C0F2AD22C3D211F006CB34D /* XCRemoteSwiftPackageReference "Luminare" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MrKai77/Luminare"; requirement = { @@ -887,9 +888,9 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - A881FC292C38A67C00853711 /* Luminare */ = { + 4C0F2AD32C3D211F006CB34D /* Luminare */ = { isa = XCSwiftPackageProductDependency; - package = A881FC282C38A67C00853711 /* XCRemoteSwiftPackageReference "Luminare" */; + package = 4C0F2AD22C3D211F006CB34D /* XCRemoteSwiftPackageReference "Luminare" */; productName = Luminare; }; A8DCC97A2980D5F500D41065 /* Defaults */ = { @@ -897,10 +898,6 @@ package = A8DCC9792980D5F500D41065 /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; - A8FF7A932C373B4500AF106E /* Luminare */ = { - isa = XCSwiftPackageProductDependency; - productName = Luminare; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A8E59C2D297F5E9A0064D4BA /* Project object */; diff --git a/Loop/Extensions/Notification+Extensions.swift b/Loop/Extensions/Notification+Extensions.swift index 80d8435c..d89531f8 100644 --- a/Loop/Extensions/Notification+Extensions.swift +++ b/Loop/Extensions/Notification+Extensions.swift @@ -15,6 +15,8 @@ extension Notification.Name { static let didLoop = Notification.Name("didLoop") static let activeStateChanged = Notification.Name("activeStateChanged") + static let keybindsUpdated = Notification.Name("keybindsUpdated") + @discardableResult func onReceive(object: Any? = nil, using: @escaping (Notification) -> ()) -> NSObjectProtocol { NotificationCenter.default.addObserver( diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index e356d339..e4c0fda6 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -3322,6 +3322,9 @@ } } } + }, + "Import keybinds from Rectangle" : { + }, "Include development versions" : { "localizations" : { @@ -3482,6 +3485,9 @@ } } } + }, + "Join Discord" : { + }, "Keybindings" : { "localizations" : { @@ -4343,7 +4349,7 @@ } } }, - "No updates available message 1" : { + "No updates available message 01" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -4354,7 +4360,7 @@ } } }, - "No updates available message 2" : { + "No updates available message 02" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -4365,7 +4371,7 @@ } } }, - "No updates available message 3" : { + "No updates available message 03" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -4376,7 +4382,7 @@ } } }, - "No updates available message 4" : { + "No updates available message 04" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -4387,7 +4393,7 @@ } } }, - "No updates available message 5" : { + "No updates available message 05" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -4398,7 +4404,7 @@ } } }, - "No updates available message 6" : { + "No updates available message 06" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -4409,7 +4415,7 @@ } } }, - "No updates available message 7" : { + "No updates available message 07" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -4420,7 +4426,7 @@ } } }, - "No updates available message 8" : { + "No updates available message 08" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { @@ -4431,7 +4437,7 @@ } } }, - "No updates available message 9" : { + "No updates available message 09" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { diff --git a/Loop/Luminare/Loop/AboutConfiguration.swift b/Loop/Luminare/Loop/AboutConfiguration.swift index f85c886a..f4cf13f1 100644 --- a/Loop/Luminare/Loop/AboutConfiguration.swift +++ b/Loop/Luminare/Loop/AboutConfiguration.swift @@ -58,15 +58,15 @@ class AboutConfigurationModel: ObservableObject { // A max of 28 W's can fit in here :) var upToDateText: [String] = [ - .init(localized: "No updates available message 1", defaultValue: "Engage! …in the current version, it's the latest."), - .init(localized: "No updates available message 2", defaultValue: "This app is more up to date than my diary entries!"), - .init(localized: "No updates available message 3", defaultValue: "You're in the clear, no updates in the atmosphere!"), - .init(localized: "No updates available message 4", defaultValue: "The odds are ever in your favor, no updates today!"), - .init(localized: "No updates available message 5", defaultValue: "Our app is on a digital diet. No new bytes allowed."), - .init(localized: "No updates available message 6", defaultValue: "New version? Sorry, we're too attached to this one."), - .init(localized: "No updates available message 7", defaultValue: "Your Loop is Loopier than ever, no updates found!"), - .init(localized: "No updates available message 8", defaultValue: "I'm giving it all she's got, Captain! No updates!"), - .init(localized: "No updates available message 9", defaultValue: "In a galaxy far, far away… still no updates!"), + .init(localized: "No updates available message 01", defaultValue: "Engage! …in the current version, it's the latest."), + .init(localized: "No updates available message 02", defaultValue: "This app is more up to date than my diary entries!"), + .init(localized: "No updates available message 03", defaultValue: "You're in the clear, no updates in the atmosphere!"), + .init(localized: "No updates available message 04", defaultValue: "The odds are ever in your favor, no updates today!"), + .init(localized: "No updates available message 05", defaultValue: "Our app is on a digital diet. No new bytes allowed."), + .init(localized: "No updates available message 06", defaultValue: "New version? Sorry, we're too attached to this one."), + .init(localized: "No updates available message 07", defaultValue: "Your Loop is Loopier than ever, no updates found!"), + .init(localized: "No updates available message 08", defaultValue: "I'm giving it all she's got, Captain! No updates!"), + .init(localized: "No updates available message 09", defaultValue: "In a galaxy far, far away… still no updates!"), .init(localized: "No updates available message 10", defaultValue: "You've got the precious, no updates needed!"), .init(localized: "No updates available message 11", defaultValue: "Riding at warp speed, no updates in sight!"), .init(localized: "No updates available message 12", defaultValue: "This is not the update you're looking for!"), @@ -236,6 +236,10 @@ struct AboutConfigurationView: View { openURL(URL(string: "https://github.com/MrKai77/Loop")!) } + Button("Join Discord") { + openURL(URL(string: "https://discord.gg/2CZ2N6PKjq")!) + } + Button("Donate") { openURL(URL(string: "https://github.com/sponsors/MrKai77")!) } diff --git a/Loop/Luminare/Loop/AdvancedConfiguration.swift b/Loop/Luminare/Loop/AdvancedConfiguration.swift index 30370bc4..edaee31a 100644 --- a/Loop/Luminare/Loop/AdvancedConfiguration.swift +++ b/Loop/Luminare/Loop/AdvancedConfiguration.swift @@ -105,6 +105,13 @@ struct AdvancedConfigurationView: View { } } + LuminareSection { + Button("Import keybinds from Rectangle") { + RectangleTranslationLayer.initiateImportProcess() + } + .buttonStyle(LuminareButtonStyle()) + } + LuminareSection("Permissions") { HStack { if model.isAccessibilityAccessGranted { diff --git a/Loop/Luminare/Settings/Keybindings/KeybindingsConfiguration.swift b/Loop/Luminare/Settings/Keybindings/KeybindingsConfiguration.swift index 77a189c7..c4fa287b 100644 --- a/Loop/Luminare/Settings/Keybindings/KeybindingsConfiguration.swift +++ b/Loop/Luminare/Settings/Keybindings/KeybindingsConfiguration.swift @@ -5,39 +5,30 @@ // Created by Kai Azim on 2024-04-20. // +import Combine import Defaults import Luminare import SwiftUI class KeybindingsConfigurationModel: ObservableObject { @Published var triggerKey = Defaults[.triggerKey] { - didSet { - Defaults[.triggerKey] = triggerKey - } + didSet { Defaults[.triggerKey] = triggerKey } } @Published var triggerDelay = Defaults[.triggerDelay] { - didSet { - Defaults[.triggerDelay] = triggerDelay - } + didSet { Defaults[.triggerDelay] = triggerDelay } } @Published var doubleClickToTrigger = Defaults[.doubleClickToTrigger] { - didSet { - Defaults[.doubleClickToTrigger] = doubleClickToTrigger - } + didSet { Defaults[.doubleClickToTrigger] = doubleClickToTrigger } } @Published var middleClickTriggersLoop = Defaults[.middleClickTriggersLoop] { - didSet { - Defaults[.middleClickTriggersLoop] = middleClickTriggersLoop - } + didSet { Defaults[.middleClickTriggersLoop] = middleClickTriggersLoop } } @Published var keybinds = Defaults[.keybinds] { - didSet { - Defaults[.keybinds] = keybinds - } + didSet { Defaults[.keybinds] = keybinds } } @Published var currentEventMonitor: NSEventMonitor? @@ -75,6 +66,8 @@ struct KeybindingsConfigurationView: View { selection: $model.selectedKeybinds, addAction: { model.keybinds.insert(.init(.noAction), at: 0) + // Post a notification that the keybinds have been updated + NotificationCenter.default.post(name: .keybindsUpdated, object: nil) }, content: { keybind in KeybindingItemView(keybind) @@ -98,5 +91,8 @@ struct KeybindingsConfigurationView: View { addText: "Add", removeText: "Remove" ) + .onReceive(.keybindsUpdated) { _ in + model.keybinds = Defaults[.keybinds] + } } } diff --git a/Loop/Utilities/RectangleTranslationLayer.swift b/Loop/Utilities/RectangleTranslationLayer.swift new file mode 100644 index 00000000..9b600fb6 --- /dev/null +++ b/Loop/Utilities/RectangleTranslationLayer.swift @@ -0,0 +1,96 @@ +// +// RectangleTranslationLayer.swift +// Loop +// +// Created by Kami on 8/7/2024. +// + +import AppKit +import Defaults +import Foundation + +/// Represents a keyboard shortcut configuration for a Rectangle action. +struct RectangleShortcut: Codable { + let keyCode: Int + let modifierFlags: Int +} + +/// Represents the configuration of Rectangle app shortcuts. +struct RectangleConfig: Codable { + let shortcuts: [String: RectangleShortcut] +} + +// Encapsulate the functions within an enum to provide a namespace +enum RectangleTranslationLayer { + /// Maps Rectangle direction keys to Loop's WindowDirection enum. + private static let directionMapping: [String: WindowDirection] = [ + "bottomHalf": .bottomHalf, + "bottomRight": .bottomRightQuarter, + "center": .center, + "larger": .larger, + "leftHalf": .leftHalf, + "maximize": .maximize, + "nextDisplay": .nextScreen, + "previousDisplay": .previousScreen, + "restore": .undo, + "rightHalf": .rightHalf, + "smaller": .smaller, + "topHalf": .topHalf, + "topLeft": .topLeftQuarter, + "topRight": .topRightQuarter + ] + + /// Translates the RectangleConfig to an array of WindowActions for Loop. + /// - Parameter rectangleConfig: The RectangleConfig instance to translate. + /// - Returns: An array of WindowAction instances corresponding to the RectangleConfig. + static func translateRectangleConfigToWindowActions(rectangleConfig: RectangleConfig) -> [WindowAction] { + // Converts the Rectangle shortcuts into Loop's WindowActions. + rectangleConfig.shortcuts.compactMap { direction, shortcut in + guard let loopDirection = directionMapping[direction], !direction.contains("Todo") else { return nil } + return WindowAction( + loopDirection, + keybind: Set([CGKeyCode(shortcut.keyCode)]), // Converts the integer keyCode to CGKeyCode. + name: direction.capitalized.replacingOccurrences(of: " ", with: "") + "Cycle" + ) + } + } + + /// Initiates the import process for the RectangleConfig.json file. + static func importRectangleConfig() { + let openPanel = NSOpenPanel() + openPanel.prompt = "Select Rectangle Config File" + openPanel.allowedContentTypes = [.json] + + // Presents a file open panel to the user. + openPanel.begin { response in + guard response == .OK, let selectedFile = openPanel.url else { return } + + // Attempts to decode the selected file into a RectangleConfig object. + if let rectangleConfig = try? JSONDecoder().decode(RectangleConfig.self, from: Data(contentsOf: selectedFile)) { + let windowActions = translateRectangleConfigToWindowActions(rectangleConfig: rectangleConfig) + saveWindowActions(windowActions) + } else { + print("Error reading or translating RectangleConfig.json") + } + } + } + + /// Saves the translated WindowActions into Loop's configuration and posts a notification. + /// - Parameter windowActions: The array of WindowActions to save. + static func saveWindowActions(_ windowActions: [WindowAction]) { + for action in windowActions { + print("Direction: \(action.direction), Keybind: \(action.keybind), Name: \(action.name ?? "")") + } + + // Stores the WindowActions into Loop's configuration. + Defaults[.keybinds] = windowActions + + // Post a notification after saving the new keybinds + NotificationCenter.default.post(name: .keybindsUpdated, object: nil) + } + + /// Starts the import process for Rectangle configuration. + static func initiateImportProcess() { + importRectangleConfig() + } +} diff --git a/Loop/Window Management/WindowAction+Port.swift b/Loop/Window Management/WindowAction+Port.swift index 0a7521bb..84e33ca0 100644 --- a/Loop/Window Management/WindowAction+Port.swift +++ b/Loop/Window Management/WindowAction+Port.swift @@ -8,13 +8,13 @@ import Defaults import SwiftUI +/// Extension of WindowAction to add functionality for saving, loading, and managing window actions. extension WindowAction { + /// Nested struct to define the format of saved window actions. private struct SavedWindowActionFormat: Codable { + // Properties representing the details of a window action. var direction: WindowDirection var keybind: Set - - // MARK: CUSTOM KEYBINDS - var name: String? var unit: CustomWindowActionUnit? var anchor: CustomWindowActionAnchor? @@ -24,65 +24,32 @@ extension WindowAction { var positionMode: CustomWindowActionPositionMode? var xPoint: Double? var yPoint: Double? - var cycle: [SavedWindowActionFormat]? + /// Converts the saved format back into a usable WindowAction object. func convertToWindowAction() -> WindowAction { - WindowAction( - direction, - keybind: keybind, - name: name, - unit: unit, - anchor: anchor, - width: width, - height: height, - xPoint: xPoint, - yPoint: yPoint, - positionMode: positionMode, - sizeMode: sizeMode, - cycle: cycle?.map { $0.convertToWindowAction() } - ) + WindowAction(direction, keybind: keybind, name: name, unit: unit, anchor: anchor, width: width, height: height, xPoint: xPoint, yPoint: yPoint, positionMode: positionMode, sizeMode: sizeMode, cycle: cycle?.map { $0.convertToWindowAction() }) } } + /// Converts a WindowAction object into the saved format. private func convertToSavedWindowActionFormat() -> SavedWindowActionFormat { - SavedWindowActionFormat( - direction: direction, - keybind: keybind, - name: name, - unit: unit, - anchor: anchor, - sizeMode: sizeMode, - width: width, - height: height, - positionMode: positionMode, - xPoint: xPoint, - yPoint: yPoint, - cycle: cycle?.map { $0.convertToSavedWindowActionFormat() } - ) + SavedWindowActionFormat(direction: direction, keybind: keybind, name: name, unit: unit, anchor: anchor, sizeMode: sizeMode, width: width, height: height, positionMode: positionMode, xPoint: xPoint, yPoint: yPoint, cycle: cycle?.map { $0.convertToSavedWindowActionFormat() }) } + /// Presents a prompt to export current keybinds to a JSON file. static func exportPrompt() { - let keybinds = Defaults[.keybinds] - - if keybinds.isEmpty { - let alert = NSAlert() - alert.messageText = "No Keybinds Have Been Set" - alert.informativeText = "You can't export something that doesn't exist!" - alert.beginSheetModal(for: NSApplication.shared.mainWindow!) + // Check if there are any keybinds to export. + guard !Defaults[.keybinds].isEmpty else { + showAlert("No Keybinds Have Been Set", informativeText: "You can't export something that doesn't exist!") return } - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - do { - let exportKeybinds = keybinds.map { - $0.convertToSavedWindowActionFormat() - } - + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let exportKeybinds = Defaults[.keybinds].map { $0.convertToSavedWindowActionFormat() } let keybindsData = try encoder.encode(exportKeybinds) - if let json = String(data: keybindsData, encoding: .utf8) { attemptSave(of: json) } @@ -91,118 +58,110 @@ extension WindowAction { } } + /// Attempts to save the exported JSON string to a file. private static func attemptSave(of keybindsData: String) { - let data = keybindsData.data(using: .utf8) - + guard let data = keybindsData.data(using: .utf8) else { return } let savePanel = NSSavePanel() - if let downloadsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { - savePanel.directoryURL = downloadsUrl - } - + savePanel.directoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first savePanel.title = "Export Keybinds" - savePanel.nameFieldStringValue = "keybinds" + savePanel.nameFieldStringValue = "keybinds.json" savePanel.allowedContentTypes = [.json] savePanel.beginSheetModal(for: NSApplication.shared.mainWindow!) { result in - if result == .OK, let destUrl = savePanel.url { - DispatchQueue.main.async { - do { - try data?.write(to: destUrl) - } catch { - print("Error writing to file: \(error.localizedDescription)") - } - } + guard result == .OK, let destUrl = savePanel.url else { return } + do { + try data.write(to: destUrl) + } catch { + print("Error writing to file: \(error.localizedDescription)") } } } + /// Presents a prompt to import keybinds from a JSON file. static func importPrompt() { let openPanel = NSOpenPanel() - openPanel.title = "Import Keybinds" + openPanel.title = "Select Loop Keybinds File" openPanel.allowedContentTypes = [.json] - openPanel.beginSheetModal(for: NSApplication.shared.mainWindow!) { result in - if result == .OK, let selectedFileURL = openPanel.url { - DispatchQueue.main.async { - do { - let jsonString = try String(contentsOf: selectedFileURL) - importKeybinds(from: jsonString) - } catch { - print("Error reading file: \(error.localizedDescription)") - } - } + guard result == .OK, let selectedFileURL = openPanel.url else { return } + do { + let jsonString = try String(contentsOf: selectedFileURL) + importKeybinds(from: jsonString) + } catch { + print("Error reading file: \(error.localizedDescription)") } } } + /// Imports keybinds from a JSON string. private static func importKeybinds(from jsonString: String) { - let decoder = JSONDecoder() - do { - let keybindsData = jsonString.data(using: .utf8)! + guard let keybindsData = jsonString.data(using: .utf8) else { return } + let decoder = JSONDecoder() let importedKeybinds = try decoder.decode([SavedWindowActionFormat].self, from: keybindsData) + updateDefaults(with: importedKeybinds) + } catch { + showAlert("Error Reading Keybinds", informativeText: "Make sure the file you selected is in the correct format.") + } + } - if Defaults[.keybinds].isEmpty { - for savedKeybind in importedKeybinds { - Defaults[.keybinds].append(savedKeybind.convertToWindowAction()) - } - } else { - showAlertForImportDecision { decision in - switch decision { - case .merge: - for savedKeybind in importedKeybinds where !Defaults[.keybinds].contains(where: { - $0.keybind == savedKeybind.keybind && $0.name == savedKeybind.name - }) { - Defaults[.keybinds].append(savedKeybind.convertToWindowAction()) - } - - case .erase: - Defaults[.keybinds] = [] - - for savedKeybind in importedKeybinds { - Defaults[.keybinds].append(savedKeybind.convertToWindowAction()) - } - - case .cancel: - break + /// Updates the app's defaults with the imported keybinds. + private static func updateDefaults(with importedKeybinds: [SavedWindowActionFormat]) { + if Defaults[.keybinds].isEmpty { + Defaults[.keybinds] = importedKeybinds.map { $0.convertToWindowAction() } + // Post a notification after updating the keybinds + NotificationCenter.default.post(name: .keybindsUpdated, object: nil) + } else { + showAlertForImportDecision { decision in + switch decision { + case .merge: + let newKeybinds = importedKeybinds.filter { savedKeybind in + !Defaults[.keybinds].contains { $0.keybind == savedKeybind.keybind && $0.name == savedKeybind.name } } + Defaults[.keybinds].append(contentsOf: newKeybinds.map { $0.convertToWindowAction() }) + // Post a notification after updating the keybinds + NotificationCenter.default.post(name: .keybindsUpdated, object: nil) + case .erase: + Defaults[.keybinds] = importedKeybinds.map { $0.convertToWindowAction() } + // Post a notification after updating the keybinds + NotificationCenter.default.post(name: .keybindsUpdated, object: nil) + case .cancel: + // No action needed, no notification should be posted + break } } - } catch { - print("Error decoding keybinds: \(error.localizedDescription)") - - let alert = NSAlert() - alert.messageText = "Error Reading Keybinds" - alert.informativeText = "Make sure the file you selected is in the correct format." - alert.beginSheetModal(for: NSApplication.shared.mainWindow!) } } + /// Presents a decision alert for how to handle imported keybinds. private static func showAlertForImportDecision(completion: @escaping (ImportDecision) -> ()) { - let alert = NSAlert() - alert.messageText = "Import Keybinds" - alert.informativeText = "Do you want to merge or erase existing keybinds?" - - alert.addButton(withTitle: "Merge") - alert.addButton(withTitle: "Erase") - alert.addButton(withTitle: "Cancel") - - alert.beginSheetModal(for: NSApplication.shared.mainWindow!) { response in + showAlert("Import Keybinds", informativeText: "Do you want to merge or erase existing keybinds?", buttons: ["Merge", "Erase", "Cancel"]) { response in switch response { - case .alertFirstButtonReturn: // Merge + case .alertFirstButtonReturn: completion(.merge) - case .alertSecondButtonReturn: // Erase + case .alertSecondButtonReturn: completion(.erase) - default: // Cancel or other cases + default: completion(.cancel) } } } - // Define an enum for the import decision + /// Utility function to show an alert with a completion handler. + private static func showAlert(_ messageText: String, informativeText: String, buttons: [String] = [], completion: ((NSApplication.ModalResponse) -> ())? = nil) { + let alert = NSAlert() + alert.messageText = messageText + alert.informativeText = informativeText + buttons.forEach { alert.addButton(withTitle: $0) } + if let completion { + alert.beginSheetModal(for: NSApplication.shared.mainWindow!, completionHandler: completion) + } else { + alert.runModal() + } + } + + /// Enum to represent the decision made in the import decision alert. enum ImportDecision { - case merge - case erase - case cancel + case merge, erase, cancel } }