diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2b125de2..4699d5a7 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* 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 /* TranslationLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0F2ACE2C3CFD09006CB34D /* TranslationLayer.swift */; }; 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 */; }; @@ -93,6 +94,7 @@ /* 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 /* TranslationLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TranslationLayer.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 = ""; }; @@ -210,6 +212,7 @@ A80900D12AA3F9F20085C63B /* Utilities */ = { isa = PBXGroup; children = ( + 4C0F2ACE2C3CFD09006CB34D /* TranslationLayer.swift */, A8D4327A2C13ED3C007BE4F2 /* Icon.swift */, A82DDBDD2AEC736300D7F974 /* AnimationConfiguration.swift */, A80900D32AA3F9F20085C63B /* VisualEffectView.swift */, @@ -571,6 +574,7 @@ A8789F6729805B190040512E /* RadialMenuView.swift in Sources */, A8330ABD2A3AC0CA00673C8D /* Bundle+Extensions.swift in Sources */, A80D49BB2BAE479900493B67 /* WindowAction+Port.swift in Sources */, + 4C0F2ACF2C3CFD09006CB34D /* TranslationLayer.swift in Sources */, A8D4327B2C13ED3C007BE4F2 /* Icon.swift in Sources */, A86B97AD2AB79E2500099D7F /* ShakeEffect.swift in Sources */, A8D6D3032B6C8D750061B11F /* PaddingPreviewView.swift in Sources */, 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 0e8222de..786b9734 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -3481,6 +3481,9 @@ } } } + }, + "Import from Rectangle" : { + }, "In a galaxy far, far away... still no updates!" : { diff --git a/Loop/Luminare/Loop/AdvancedConfiguration.swift b/Loop/Luminare/Loop/AdvancedConfiguration.swift index b81af96d..96cba9e3 100644 --- a/Loop/Luminare/Loop/AdvancedConfiguration.swift +++ b/Loop/Luminare/Loop/AdvancedConfiguration.swift @@ -100,6 +100,13 @@ struct AdvancedConfigurationView: View { } } + LuminareSection { + Button("Import from Rectangle") { + 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 54b4589f..42707d4c 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(NotificationCenter.default.publisher(for: .keybindsUpdated)) { _ in + model.keybinds = Defaults[.keybinds] + } } } diff --git a/Loop/Utilities/TranslationLayer.swift b/Loop/Utilities/TranslationLayer.swift new file mode 100644 index 00000000..a716c662 --- /dev/null +++ b/Loop/Utilities/TranslationLayer.swift @@ -0,0 +1,93 @@ +// +// TranslationLayer.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] +} + +/// Translates the RectangleConfig to an array of WindowActions for Loop. +/// - Parameter rectangleConfig: The RectangleConfig instance to translate. (this works w/ both normal and pro) +/// - Returns: An array of WindowAction instances corresponding to the RectangleConfig. +func translateRectangleConfigToWindowActions(rectangleConfig: RectangleConfig) -> [WindowAction] { + // Maps Rectangle direction keys to Loop's WindowDirection enum. + 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 + ] + + // Converts the Rectangle shortcuts into Loop's WindowActions. + return 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. +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. +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. +func initiateImportProcess() { + importRectangleConfig() +}