Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Keybind import/export/reset improvements #646

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Loop/Extensions/Notification+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ extension Notification.Name {

static let keybindsUpdated = Notification.Name("keybindsUpdated")

static let didImportKeybindsSuccessfully = Notification.Name("didImportKeybindsSuccessfully")
static let didExportKeybindsSuccessfully = Notification.Name("didExportKeybindsSuccessfully")

@discardableResult
func onReceive(object: Any? = nil, using: @escaping (Notification) -> ()) -> NSObjectProtocol {
NotificationCenter.default.addObserver(
Expand Down
6 changes: 6 additions & 0 deletions Loop/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -17962,6 +17962,9 @@
}
}
}
},
"Select a keybinds file" : {

},
"Select Loop keybinds file" : {
"extractionState" : "manual",
Expand Down Expand Up @@ -20426,6 +20429,9 @@
}
}
}
},
"There are other keybinds that conflict with this key combination." : {

},
"Thickness" : {
"extractionState" : "manual",
Expand Down
107 changes: 97 additions & 10 deletions Loop/Luminare/Loop/AdvancedConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,57 @@ class AdvancedConfigurationModel: ObservableObject {
didSet { Defaults[.sizeIncrement] = sizeIncrement }
}

@Published var didImportSuccessfullyAlert = false
@Published var didExportSuccessfullyAlert = false
@Published var didResetSuccessfullyAlert = false

@Published var isAccessibilityAccessGranted = AccessibilityManager.getStatus()
@Published var isScreenCaptureAccessGranted = ScreenCaptureManager.getStatus()
@Published var accessibilityChecker: Publishers.Autoconnect<Timer.TimerPublisher> = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@Published var accessibilityChecks: Int = 0

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 exportedSuccessfully() {
DispatchQueue.main.async { [weak self] in
withAnimation(.smooth(duration: 0.5)) {
self?.didExportSuccessfullyAlert = true
}
}

DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
withAnimation(.smooth(duration: 0.5)) {
self?.didExportSuccessfullyAlert = false
}
}
}

func resetSuccessfully() {
DispatchQueue.main.async { [weak self] in
withAnimation(.smooth(duration: 0.5)) {
self?.didResetSuccessfullyAlert = true
}
}

DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
withAnimation(.smooth(duration: 0.5)) {
self?.didResetSuccessfullyAlert = false
}
}
}

func beginAccessibilityAccessRequest() {
accessibilityChecker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
accessibilityChecks = 0
Expand Down Expand Up @@ -73,6 +119,12 @@ struct AdvancedConfigurationView: View {
let elementHeight: CGFloat = 34

var body: some View {
generalSection()
keybindsSection()
permissionsSection()
}

func generalSection() -> some View {
LuminareSection("General") {
if #available(macOS 15.0, *) {
LuminareToggle("Use macOS window manager when available", isOn: $model.useSystemWindowManagerWhenAvailable)
Expand All @@ -96,31 +148,66 @@ struct AdvancedConfigurationView: View {
lowerClamp: true
)
}
}

func keybindsSection() -> some View {
LuminareSection("Keybinds") {
HStack(spacing: 2) {
Button("Import") {
Button {
WindowAction.importPrompt()
} label: {
HStack {
Text("Import")

if model.didImportSuccessfullyAlert {
Image(systemName: "checkmark")
.foregroundStyle(tintColor())
.bold()
}
}
}
.onReceive(.didImportKeybindsSuccessfully) { _ in
model.importedSuccessfully()
}

Button("Export") {
Button {
WindowAction.exportPrompt()
} label: {
HStack {
Text("Export")

if model.didExportSuccessfullyAlert {
Image(systemName: "checkmark")
.foregroundStyle(tintColor())
.bold()
}
}
}
.onReceive(.didExportKeybindsSuccessfully) { _ in
model.exportedSuccessfully()
}

Button("Reset") {
Button {
Defaults.reset(.keybinds)
model.resetSuccessfully()
Notification.Name.keybindsUpdated.post()
} label: {
HStack {
Text("Reset")

if model.didResetSuccessfullyAlert {
Image(systemName: "checkmark")
.foregroundStyle(tintColor())
.bold()
}
}
}
.buttonStyle(LuminareDestructiveButtonStyle())
}
}
}

LuminareSection {
Button("Import keybinds from Rectangle") {
RectangleTranslationLayer.initiateImportProcess()
}
.buttonStyle(LuminareButtonStyle())
}

func permissionsSection() -> some View {
LuminareSection("Permissions") {
accessibilityComponent()
screenCaptureComponent()
Expand Down
17 changes: 17 additions & 0 deletions Loop/Luminare/Settings/Keybindings/KeybindItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ struct KeybindItemView: View {
.modifier(LuminareBordered())
} else {
HStack(spacing: 6) {
let hasConflicts = hasDuplicateKeybinds()

if hasConflicts {
LuminareInfoView(
"There are other keybinds that conflict with this key combination.",
.red
)
}

HStack {
ForEach(triggerKey.sorted().compactMap(\.systemImage), id: \.self) { image in
Text("\(Image(systemName: image))")
Expand All @@ -129,6 +138,7 @@ struct KeybindItemView: View {
Image(systemName: "plus")

Keycorder($keybind)
.opacity(hasConflicts ? 0.5 : 1)
}
.fixedSize()
}
Expand Down Expand Up @@ -182,6 +192,13 @@ struct KeybindItemView: View {
.help("Customize this keybind's action.")
}

/// Checks if there are any existing keybinds with the same key combination
func hasDuplicateKeybinds() -> Bool {
Defaults[.keybinds]
.filter { $0.keybind == keybind.keybind }
.count > 1
}

func directionPicker() -> some View {
VStack {
Button {
Expand Down
67 changes: 27 additions & 40 deletions Loop/Utilities/RectangleTranslationLayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import AppKit
import Defaults
import Foundation

/// Represents an error that can occur in the RectangleTranslationLayer.
enum RectangleTranslationLayerError: Error {
case dataLoadFailed

var localizedString: String {
switch self {
case .dataLoadFailed:
"Failed to convert string to data."
}
}
}

/// Represents a keyboard shortcut configuration for a Rectangle action.
struct RectangleShortcut: Codable {
let keyCode: Int
Expand Down Expand Up @@ -42,10 +54,24 @@ enum RectangleTranslationLayer {
"topRight": .topRightQuarter
]

/// Imports the keybinds from a JSON string.
/// - Parameter jsonString: The JSON string to import the keybinds from.
/// - Returns: An array of WindowAction instances corresponding to the keybinds.
static func importKeybinds(from jsonString: String) throws -> [WindowAction] {
guard let data = jsonString.data(using: .utf8) else {
throw RectangleTranslationLayerError.dataLoadFailed
}

let rectangleConfig = try JSONDecoder().decode(RectangleConfig.self, from: data)
let windowActions = translateRectangleConfigToWindowActions(rectangleConfig: rectangleConfig)

return windowActions
}

/// 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] {
private 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 }
Expand All @@ -56,43 +82,4 @@ enum RectangleTranslationLayer {
)
}
}

/// Initiates the import process for the RectangleConfig.json file.
static func importRectangleConfig() {
let openPanel = NSOpenPanel()
openPanel.prompt = .init(localized: "Import from Rectangle", defaultValue: "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()
}
}
Loading