diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 82b2fd9f..c22405dd 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -25,7 +25,7 @@ jobs: - name: 🧪 Set Version and Build number run: | # CHANGE EVERY RELEASE: - APP_VERSION="🧪 1.4.1" + APP_VERSION="🧪 1.4.3" APP_BUILD=$(git rev-list --count HEAD) FROM_TAG=$(git describe --tags --abbrev=0 --exclude='prerelease') diff --git a/Loop/Core/Observers/KeybindTrigger.swift b/Loop/Core/Observers/KeybindTrigger.swift index 1617291f..87f4c28e 100644 --- a/Loop/Core/Observers/KeybindTrigger.swift +++ b/Loop/Core/Observers/KeybindTrigger.swift @@ -152,8 +152,10 @@ final class KeybindTrigger { private func performKeybind(keyCode: CGKeyCode, type: CGEventType, isARepeat: Bool, flags: CGEventFlags, isLoopOpen: Bool) -> PerformKeybindResult { let flagKeys = sideDependentTriggerKey ? flags.keyCodes : flags.keyCodes.baseModifiers let allPressedKeys: Set = pressedKeys.union(flagKeys) + let actionKeys: Set = Set(allPressedKeys.subtracting(triggerKey).map(\.baseModifier)) let containsTrigger = allPressedKeys.isSuperset(of: triggerKey) + let allPressedKeysBaseModifiers: Set = Set(allPressedKeys.map(\.baseModifier)) if isLoopOpen { if pressedKeys.contains(.kVK_Escape) { @@ -175,10 +177,9 @@ final class KeybindTrigger { if containsTrigger { // Try an match directly with the action keys first, then fallback to just the key code. // This prevents failures when the user is tapping the keys in rapid succession. - let initalMatch = windowActionCache.actionsByKeybind[actionKeys] - let fallbackMatch = windowActionCache.actionsByKeybind[[keyCode]] + let match = windowActionCache.actionsByKeybind[actionKeys] ?? windowActionCache.actionsByKeybind[[keyCode]] - if let action = initalMatch ?? fallbackMatch { + if let action = match { if !isARepeat || action.canRepeat { openLoop(startingAction: action, overrideExistingTriggerDelayTimerAction: true) } @@ -196,6 +197,12 @@ final class KeybindTrigger { ) return .opening } + } else if let bypassedAction = windowActionCache.bypassedActionsByKeybind[allPressedKeysBaseModifiers] { + if !isARepeat || bypassedAction.canRepeat { + openLoop(startingAction: bypassedAction, overrideExistingTriggerDelayTimerAction: true) + } + + return checkIfLoopOpen() ? .consume : .opening } else { if allPressedKeys.isEmpty { doubleClickTimer.handleKeyUp() diff --git a/Loop/Core/WindowDragManager.swift b/Loop/Core/WindowDragManager.swift index 55914315..37b7e676 100644 --- a/Loop/Core/WindowDragManager.swift +++ b/Loop/Core/WindowDragManager.swift @@ -9,6 +9,7 @@ import Defaults import Scribe import SwiftUI +@MainActor final class WindowDragManager { static let shared = WindowDragManager() private init() {} @@ -42,7 +43,6 @@ final class WindowDragManager { !Defaults[.stashManagerStashedWindows].isEmpty } - @MainActor func addObservers() { accessibilityCheckerTask = Task(priority: .background) { [weak self] in for await status in AccessibilityManager.shared.stream(initial: true) { @@ -90,7 +90,7 @@ final class WindowDragManager { return } - Task { @MainActor in + Task { guard let initialMousePosition else { initialMousePosition = currentMousePosition return @@ -110,9 +110,6 @@ final class WindowDragManager { } if let window = draggingWindow, let initialFrame = initialWindowFrame, hasWindowResized(window.frame, initialFrame) { - StashManager.shared.onWindowDragged(window.cgWindowID) - WindowRecords.eraseRecords(for: window) - if hasWindowMoved(window.frame, initialFrame) { if Defaults[.restoreWindowFrameOnDrag] { restoreInitialWindowSize(window) @@ -131,28 +128,33 @@ final class WindowDragManager { processSnapAction() } } + + StashManager.shared.onWindowDragged(window.cgWindowID) + WindowRecords.eraseRecords(for: window) } } } private func leftMouseUp(_: CGEvent) { - Task { @MainActor in + guard Defaults[.windowSnapping] else { + return + } + + Task { + previewController.close() + if let window = draggingWindow, + let screen = NSScreen.screenWithMouse, let initialFrame = initialWindowFrame, hasWindowMoved(window.frame, initialFrame) { - if Defaults[.windowSnapping] { - attemptWindowSnap(window) - } + WindowEngine.resize(window, to: .init(direction), on: screen) } - previewController.close() draggingWindow = nil - resetDragState() } } - @MainActor private func setCurrentDraggingWindow() { guard determineDraggedWindowTask == nil else { return @@ -163,9 +165,8 @@ final class WindowDragManager { determineDraggedWindowTask = nil } - guard - let draggingWindow = try? WindowUtility.windowAtPosition(currentMousePosition), - !draggingWindow.isAppExcluded + guard let draggingWindow = try? WindowUtility.windowAtPosition(currentMousePosition), + !draggingWindow.isAppExcluded else { didFailToResolveDraggedWindow = true return @@ -263,41 +264,29 @@ final class WindowDragManager { await AccentColorController.shared.refresh() } - direction = WindowDirection.getSnapDirection( + let newDirection = WindowDirection.getSnapDirection( mouseLocation: currentMousePosition, - currentDirection: direction, + currentDirection: oldDirection, screenFrame: screenFrame, ignoredFrame: ignoredFrame ) - Log.info("Window snapping direction changed: \(direction.debugDescription)", category: .windowDragManager) + // Only update if direction actually changed + if newDirection != oldDirection { + direction = newDirection - previewController.open(screen: screen, window: draggingWindow, startingAction: nil) - previewController.setAction(to: WindowAction(direction)) - } else { - direction = .noAction - previewController.close() - } - - if direction != oldDirection { - if Defaults[.hapticFeedback] { - NSHapticFeedbackManager.defaultPerformer.perform( - NSHapticFeedbackManager.FeedbackPattern.alignment, - performanceTime: NSHapticFeedbackManager.PerformanceTime.now - ) - } - } - } + Log.info("Window snapping direction changed: \(newDirection.debugDescription)", category: .windowDragManager) - private func attemptWindowSnap(_ window: Window) { - guard let screen = NSScreen.screenWithMouse else { - return - } + previewController.open(screen: screen, window: draggingWindow, startingAction: nil) + previewController.setAction(to: WindowAction(newDirection)) - let snapDirection = direction - DispatchQueue.main.async { - WindowEngine.resize(window, to: .init(snapDirection), on: screen) - self.direction = .noAction + if newDirection != .noAction, Defaults[.hapticFeedback] { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) + } + } + } else if !(oldDirection == .noAction || oldDirection == .noSelection) { + direction = .noAction + previewController.close() } } } diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 34db9005..e01fc912 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -302,8 +302,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "%@ is not in your Applications folder. Would you like to install the update to your Applications folder instead?" + "state" : "translated", + "value" : "%@ staat niet in je Applications-folder. Wil je de update in je Applications-folder installeren?" } }, "pt-BR" : { @@ -549,8 +549,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "A single %1$@ action can only track one window. To stash\nmultiple windows, add additional %2$@ actions." + "state" : "translated", + "value" : "Een enkele %1$@-actie kan maar één venster volgen. Om meerdere vensters tijdelijk op te slaan, voeg extra %2$@-acties toe." } }, "pt-BR" : { @@ -632,8 +632,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Accent opacity" + "state" : "translated", + "value" : "Accentdekking" } }, "pt-BR" : { @@ -961,8 +961,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Actions" + "state" : "translated", + "value" : "Acties" } }, "pt-BR" : { @@ -1456,8 +1456,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Animated" + "state" : "translated", + "value" : "Geanimeerd" } }, "pt-BR" : { @@ -1867,8 +1867,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Automatically install updates" + "state" : "translated", + "value" : "Automatisch updates installeren" } }, "pt-BR" : { @@ -2943,8 +2943,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Cancel" + "state" : "translated", + "value" : "Annuleren" } }, "pt-BR" : { @@ -3273,8 +3273,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Check for Updates…" + "state" : "translated", + "value" : "Controleren op updates…" } }, "pt-BR" : { @@ -3303,6 +3303,88 @@ } } }, + "Clear Keybind" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear Keybind" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer le raccourci" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キー設定をクリア" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Clear Keybind" + } + } + } + }, "Close" : { "comment" : "Label for a button that closes a modal window", "localizations" : { @@ -3686,8 +3768,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Copied!" + "state" : "translated", + "value" : "Gekopiëerd!" } }, "pt-BR" : { @@ -6081,8 +6163,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Failed to resolve linked keybind" + "state" : "translated", + "value" : "Gelinkte toetsverbinding mislukt" } }, "pt-BR" : { @@ -6164,8 +6246,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Fill Available Space" + "state" : "translated", + "value" : "Beschikbare ruimte opvullen" } }, "pt-BR" : { @@ -6247,8 +6329,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "First Fourth" + "state" : "translated", + "value" : "Eerste kwart" } }, "pt-BR" : { @@ -6330,8 +6412,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Fluid" + "state" : "translated", + "value" : "Vloeiend" } }, "pt-BR" : { @@ -6413,7 +6495,7 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Focus" } }, @@ -6496,8 +6578,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Focus Down" + "state" : "translated", + "value" : "Focus beneden" } }, "pt-BR" : { @@ -6579,8 +6661,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Focus Left" + "state" : "translated", + "value" : "Focus links" } }, "pt-BR" : { @@ -6745,8 +6827,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Focus Right" + "state" : "translated", + "value" : "Focus rechts" } }, "pt-BR" : { @@ -6828,8 +6910,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Focus Up" + "state" : "translated", + "value" : "Focus omhoog" } }, "pt-BR" : { @@ -6993,8 +7075,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Fourth Fourth" + "state" : "translated", + "value" : "Vierde kwart" } }, "pt-BR" : { @@ -7573,8 +7655,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Grow Horizontally" + "state" : "translated", + "value" : "Groei horizontaal" } }, "pt-BR" : { @@ -7905,8 +7987,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Grow Vertically" + "state" : "translated", + "value" : "Groei verticaal" } }, "pt-BR" : { @@ -8482,8 +8564,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Horizontal Center Half" + "state" : "translated", + "value" : "Horizontaal halfmidden" } }, "pt-BR" : { @@ -8648,8 +8730,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Horizontal Fourths" + "state" : "translated", + "value" : "Horizontale vierdes" } }, "pt-BR" : { @@ -9059,8 +9141,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Daylight" + "state" : "translated", + "value" : "Daglicht" } }, "pt-BR" : { @@ -11114,8 +11196,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Install failed" + "state" : "translated", + "value" : "Installatie mislukt" } }, "pt-BR" : { @@ -11197,8 +11279,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Install to Applications" + "state" : "translated", + "value" : "Installeren in je Applications-folder" } }, "pt-BR" : { @@ -11445,8 +11527,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Keep in Current Location" + "state" : "translated", + "value" : "In huidige locatie houden" } }, "pt-BR" : { @@ -11694,8 +11776,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Larger (Proportional)" + "state" : "translated", + "value" : "Groter (proportioneel)" } }, "pt-BR" : { @@ -12107,8 +12189,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Left Screen" + "state" : "translated", + "value" : "Linker scherm" } }, "pt-BR" : { @@ -12273,8 +12355,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Left Three Fourths" + "state" : "translated", + "value" : "Linker driekwart" } }, "pt-BR" : { @@ -12439,8 +12521,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Left-click to step through cycle actions." + "state" : "translated", + "value" : "Klik met de linkermuisknop om door cycle-acties te stappen" } }, "pt-BR" : { @@ -12469,6 +12551,89 @@ } } }, + "Link Trigger Key" : { + "comment" : "A button that links a trigger key to an action's keybind.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Link Trigger Key" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lier la touche d’activation" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "トリガーキーをリンク" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Link Trigger Key" + } + } + } + }, "Locked icon alert title" : { "localizations" : { "ar" : { @@ -15232,8 +15397,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "macOS's \"Tile by dragging windows to screen edges\" feature is currently\nenabled, which will conflict with Loop's window snapping functionality." + "state" : "translated", + "value" : "MacOS' \"Sleep vensters naar de linker- of rechterrand van het scherm om te plaatsen\"-functie is actief, wat conflicteert met de vastgrijpfunctie van Loop" } }, "pt-BR" : { @@ -15398,8 +15563,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Maximize Height" + "state" : "translated", + "value" : "Hoogte maximaliseren" } }, "pt-BR" : { @@ -15481,8 +15646,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Maximize Width" + "state" : "translated", + "value" : "Breedte maximaliseren" } }, "pt-BR" : { @@ -15729,8 +15894,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Minimize Others" + "state" : "translated", + "value" : "Andere minimaliseren" } }, "pt-BR" : { @@ -16309,8 +16474,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Move to Applications Folder?" + "state" : "translated", + "value" : "Naar Applications-folder verplaatsen?" } }, "pt-BR" : { @@ -16970,8 +17135,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "No radial menu actions" + "state" : "translated", + "value" : "Geen radiaalmenu-acties" } }, "pt-BR" : { @@ -21513,6 +21678,89 @@ } } }, + "Please include at least one modifier key." : { + "comment" : "An error message displayed when a custom keybind is created but does not include at least one modifier key.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please include at least one modifier key." + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veuillez inclure au moins une touche de modification." + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "少なくとも1つの修飾キーを含めてください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Please include at least one modifier key." + } + } + } + }, "Position" : { "localizations" : { "ar" : { @@ -21894,8 +22142,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Press \"Add\" to add an action" + "state" : "translated", + "value" : "Klik op \"Toevoegen\" om een actie toe te voegen" } }, "pt-BR" : { @@ -22638,8 +22886,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Radial Menu" + "state" : "translated", + "value" : "Radiaalmenu" } }, "pt-BR" : { @@ -23300,8 +23548,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Reset radial menu actions" + "state" : "translated", + "value" : "Radiaalmenu-acties resetten" } }, "pt-BR" : { @@ -23959,8 +24207,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Right Screen" + "state" : "translated", + "value" : "Rechter scherm" } }, "pt-BR" : { @@ -27755,8 +28003,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Suppress Mission Control" + "state" : "translated", + "value" : "Mission Control onderdrukken" } }, "pt-BR" : { @@ -28578,8 +28826,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "This action is linked to a keybind. Changes made to this action will affect both." + "state" : "translated", + "value" : "Deze actie is gekoppeld aan een toets. Aanpassingen aan deze actie zal effect hebben op beide" } }, "pt-BR" : { @@ -28827,8 +29075,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "This will reset all radial menu actions to their default configuration." + "state" : "translated", + "value" : "Dit zal alle radiaalmenu's resetten naar hun standaardinstellingen" } }, "pt-BR" : { @@ -29406,8 +29654,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Top Screen" + "state" : "translated", + "value" : "Bovenste scherm" } }, "pt-BR" : { @@ -29903,8 +30151,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Try again later" + "state" : "translated", + "value" : "Probeer later nogmaals" } }, "pt-BR" : { @@ -30016,6 +30264,89 @@ } } }, + "Unlink Trigger Key" : { + "comment" : "A menu item that removes the trigger key from the keybind.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlink Trigger Key" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Délier la touche d’activation" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "トリガーキーのリンク解除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + }, + "nl-BE" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unlink Trigger Key" + } + } + } + }, "Unstash" : { "comment" : "Window action", "localizations" : { @@ -30234,8 +30565,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Update from: %@" + "state" : "translated", + "value" : "Update van: %@" } }, "pt-BR" : { @@ -30482,8 +30813,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Updates will only be installed when %@ is in the background." + "state" : "translated", + "value" : "Updates zullen worden geïnstalleerd wanneer %@ op de achtergrond is" } }, "pt-BR" : { @@ -30976,8 +31307,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Version %@" + "state" : "translated", + "value" : "Versie %@" } }, "pt-BR" : { @@ -31391,8 +31722,8 @@ }, "nl-BE" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Whether to allow Mission Control to open when windows\nare dragged to the top of the screen." + "state" : "translated", + "value" : "Sta Mission Control toe te openen wanneer vensters naar de bovenkant van het scherm worden gesleept" } }, "pt-BR" : { @@ -32000,84 +32331,85 @@ } } }, - "You can only use up to %lld keys in a keybind, including the trigger key." : { + "You can only use up to %lld keys in a keybind." : { + "comment" : "An error message displayed when a user tries to create a keybind with more than the maximum number of keys allowed. The placeholder is replaced with the actual number of keys allowed.", "localizations" : { "ar" : { "stringUnit" : { - "state" : "translated", - "value" : "يمكنك استخدام حتى %lld مفاتيح في ربط المفتاح، بما في ذلك مفتاح التشغيل." + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } }, "de" : { "stringUnit" : { - "state" : "translated", - "value" : "Du kannst in einem Tastenkürzel nur bis zu %lld Tasten verwenden, einschließlich der Aktivierungstaste." + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "You can only use up to %lld keys in a keybind, including the trigger key." + "value" : "You can only use up to %lld keys in a keybind." } }, "es" : { "stringUnit" : { - "state" : "translated", - "value" : "Solo se pueden usar hasta %lld teclas en una combinación, incluyendo la tecla de activación" + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Vous pouvez utiliser un maximum de %lld touches par raccourci, touches d'activation incluses." + "value" : "Vous ne pouvez utiliser que %lld touches dans un raccourci." } }, "it" : { "stringUnit" : { - "state" : "translated", - "value" : "Puoi usare un massimo di %lld tasti nelle scorciatoie, compresi i tasti di attivazione." + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "キー設定では、トリガーキーも含めて %lld キーまで使えます。" + "value" : "キー設定には最大で%lld個のキーしか使用できません。" } }, "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "트리거 키를 포함하여 키바인드에는 최대 %lld 개의 키만 사용할 수 있습니다." + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } }, "nl-BE" : { "stringUnit" : { - "state" : "translated", - "value" : "Je kunt maximaal %lld toetsen gebruiken in een toetscombinatie, inclusief de activatie knop." + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", - "value" : "Você pode usar até %lld teclas em um atalho, incluindo a tecla de gatilho." + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } }, "ru" : { "stringUnit" : { - "state" : "translated", - "value" : "Вы можете использовать до %lld клавиш в этом сочетании, включая клавишу активации." + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "在一个快捷键中,你最多只能使用%lld个按键(包括触发键)。" + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", - "value" : "在一個按鍵綁定中,你最多只能使用%lld個按鍵(包括觸發鍵)。" + "state" : "needs_review", + "value" : "You can only use up to %lld keys in a keybind." } } } diff --git a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift index a9862ef8..ebf99438 100644 --- a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift +++ b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/Keycorder.swift @@ -21,6 +21,7 @@ struct Keycorder: View { @Binding private var validCurrentKeybind: Set @State private var selectionKeybind: Set @Binding private var direction: WindowDirection + @Binding private var bypassTriggerKey: Bool? @State private var eventMonitor: LocalEventMonitor? @State private var shouldShake: Bool = false @@ -33,6 +34,7 @@ struct Keycorder: View { init(_ keybind: Binding) { self._validCurrentKeybind = keybind.keybind self._direction = keybind.direction + self._bypassTriggerKey = keybind.bypassTriggerKey self._selectionKeybind = State(initialValue: keybind.wrappedValue.keybind) } @@ -82,7 +84,7 @@ struct Keycorder: View { isHovering = hovering } .onChange(of: model.currentEventMonitor) { _ in - if model.currentEventMonitor != eventMonitor { + if let eventMonitor, model.currentEventMonitor != eventMonitor { finishedObservingKeys(wasForced: true) } } @@ -104,6 +106,9 @@ struct Keycorder: View { func startObservingKeys() { selectionKeybind = [] isActive = true + + LoopManager.shared.keybindTrigger.stop() + eventMonitor = LocalEventMonitor(events: [.keyDown, .keyUp]) { event in // Handle regular key presses first if event.type == .keyDown, !event.isARepeat { @@ -133,61 +138,43 @@ struct Keycorder: View { let currentKeys = selectionKeybind + [event.keyCode] .map { $0.baseKey(flags: event.modifierFlags) } - var flags = CGEventFlags(cocoaFlags: event.modifierFlags) + var flags = CGEventFlags( + cocoaFlags: event.modifierFlags + .intersection(.deviceIndependentFlagsMask) // Prevents right/left dependence + ) if event.keyCode.isFnSpecialKey { flags.remove(.maskSecondaryFn) } - // Filter out trigger keys from flags - let validModifiers = flags.keyCodes.map(\.baseModifier).filter { - !Defaults[.triggerKey] - .map(\.baseModifier) - .contains($0) + let validModifiers = if bypassTriggerKey == true { + flags.keyCodes + } else { + flags.keyCodes.filter { + !Defaults[.triggerKey] + .map(\.baseModifier) + .contains($0) + } } let finalKeys = Set(currentKeys + validModifiers) + shouldError = false + /// Make sure we don't go over the key limit - guard finalKeys.count < keyLimit else { - errorMessage = "You can only use up to \(keyLimit) keys in a keybind, including the trigger key." - shouldShake.toggle() + guard finalKeys.count <= keyLimit else { + errorMessage = "You can only use up to \(keyLimit) keys in a keybind." + shake() shouldError = true return } - shouldError = false selectionKeybind = finalKeys } func finishedObservingKeys(wasForced: Bool = false) { isActive = false - var willSet = !wasForced - - if validCurrentKeybind == selectionKeybind { - willSet = false - } - - if willSet { - for keybind in Defaults[.keybinds] where - keybind.keybind == selectionKeybind { - willSet = false - - if let name = keybind.name, !name.isEmpty { - self.errorMessage = "That keybind is already being used by \(name)." - } else if keybind.direction == .custom { - self.errorMessage = "That keybind is already being used by another custom keybind." - } else if keybind.direction == .stash { - self.errorMessage = "That keybind is already being used by another stash keybind." - } else { - self.errorMessage = "That keybind is already being used by \(keybind.direction.name.lowercased())." - } - - self.shouldShake.toggle() - self.shouldError = true - break - } - } + let willSet = !wasForced && checkValidKeybindConditions() if willSet { // Set the valid keybind to the current selected one @@ -199,5 +186,56 @@ struct Keycorder: View { eventMonitor?.stop() eventMonitor = nil + + LoopManager.shared.keybindTrigger.start() + } + + private func checkValidKeybindConditions() -> Bool { + if validCurrentKeybind == selectionKeybind { + return false + } + + // Validate keybind requirements when in bypass mode + if bypassTriggerKey == true, + selectionKeybind.filter(\.isModifier).isEmpty { + errorMessage = "Please include at least one modifier key." + shake() + shouldError = true + return false + } + + let effectiveSelection = bypassTriggerKey == true + ? selectionKeybind + : triggerKey.union(selectionKeybind) + + for keybind in Defaults[.keybinds] { + let effectiveExisting = keybind.bypassTriggerKey == true + ? keybind.keybind + : triggerKey.union(keybind.keybind) + + guard effectiveSelection == effectiveExisting else { continue } + + if let name = keybind.name, !name.isEmpty { + errorMessage = "That keybind is already being used by \(name)." + } else if keybind.direction == .custom { + errorMessage = "That keybind is already being used by another custom keybind." + } else if keybind.direction == .stash { + errorMessage = "That keybind is already being used by another stash keybind." + } else { + errorMessage = "That keybind is already being used by \(keybind.direction.name.lowercased())." + } + + shake() + shouldError = true + return false + } + + return true + } + + private func shake() { + Task { + shouldShake.toggle() + } } } diff --git a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift index b25d451f..ea7eae33 100644 --- a/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift +++ b/Loop/Settings Window/Settings/Keybinds/Keybind Recorder/TriggerKeycorder.swift @@ -96,7 +96,7 @@ struct TriggerKeycorder: View { isHovering = hovering } .onChange(of: model.currentEventMonitor) { _ in - if model.currentEventMonitor != eventMonitor { + if let eventMonitor, model.currentEventMonitor != eventMonitor { finishedObservingKeys(wasForced: true) } } @@ -129,7 +129,6 @@ struct TriggerKeycorder: View { selectionKey = [] isActive = true - // So that if doesn't interfere with the key detection here LoopManager.shared.keybindTrigger.stop() eventMonitor = LocalEventMonitor(events: [.keyDown, .flagsChanged]) { event in @@ -148,7 +147,7 @@ struct TriggerKeycorder: View { } if !keycodes.isEmpty, selectionKey.isEmpty { - shouldShake.toggle() + shake() } return nil @@ -163,7 +162,7 @@ struct TriggerKeycorder: View { if selectionKey.count > keyLimit { willSet = false - shouldShake.toggle() + shake() tooManyKeysPopup = true } @@ -182,6 +181,12 @@ struct TriggerKeycorder: View { LoopManager.shared.keybindTrigger.start() } + + private func shake() { + Task { + shouldShake.toggle() + } + } } struct TriggerKeycorderKeyView: View { diff --git a/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift index e0fdebab..3f4da81b 100644 --- a/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift +++ b/Loop/Settings Window/Settings/Keybinds/KeybindItemView.swift @@ -32,8 +32,21 @@ struct KeybindItemView: View { /// Checks if there are any existing keybinds with the same key combination private var hasDuplicateKeybinds: Bool { - keybinds - .count { $0.keybind == action.keybind } > 1 + guard !action.keybind.isEmpty else { + return false + } + + let effectiveKeybind = action.bypassTriggerKey == true + ? action.keybind + : triggerKey.union(action.keybind) + + return keybinds.contains { otherAction in + guard otherAction.id != action.id else { return false } + let otherEffectiveKeybind = otherAction.bypassTriggerKey == true + ? otherAction.keybind + : triggerKey.union(otherAction.keybind) + return effectiveKeybind == otherEffectiveKeybind + } } var body: some View { @@ -44,7 +57,6 @@ struct KeybindItemView: View { keybindCombination .frame(maxWidth: .infinity, alignment: .trailing) } - .padding(.horizontal, 12) .onChange(of: isHovering) { _ in if !isHovering { @@ -148,17 +160,13 @@ struct KeybindItemView: View { .luminarePlateau() } else { HStack(spacing: 6) { - if hasDuplicateKeybinds { - keycorderSection(hasConflicts: true) - .padding(.leading, 4) - .luminarePopover(attachedTo: .topLeading) { - Text("There are other keybinds that conflict with this key combination.") - .padding(6) - } - .luminareTint(overridingWith: .red) - } else { - keycorderSection(hasConflicts: false) - } + keycorderSection() + .padding(.leading, 4) + .luminarePopover(attachedTo: .topLeading, hidden: !hasDuplicateKeybinds) { + Text("There are other keybinds that conflict with this key combination.") + .padding(6) + } + .luminareTint(overridingWith: .red) } .fixedSize() } @@ -166,6 +174,26 @@ struct KeybindItemView: View { .luminareCornerRadius(8) } + // MARK: - Helper Methods + + /// Switches to standard mode (keeps the keybind) + private func restoreStandardMode() { + action.keybind = action.keybind.subtracting(triggerKey) + action.bypassTriggerKey = false + } + + /// Merges trigger key into action key and switches to bypass mode + private func switchToBypassMode() { + action.keybind = triggerKey.union(action.keybind) + action.bypassTriggerKey = true + } + + /// Clears the keybind and switches to standard mode + private func clearKeybind() { + action.keybind = [] + action.bypassTriggerKey = false + } + private func label() -> some View { Button { isDirectionPickerPresented.toggle() @@ -199,23 +227,34 @@ struct KeybindItemView: View { .padding(.leading, -4) } - private func keycorderSection(hasConflicts: Bool) -> some View { + private func keycorderSection() -> some View { HStack(spacing: 6) { - HStack(spacing: 6) { - ForEach(triggerKey.sorted().compactMap(\.modifierSystemImage), id: \.self) { image in - Text("\(Image(systemName: image))") + if action.bypassTriggerKey != true { + HStack(spacing: 6) { + ForEach(triggerKey.sorted().compactMap(\.modifierSystemImage), id: \.self) { image in + Text("\(Image(systemName: image))") + } } - } - .font(.callout) - .padding(6) - .frame(height: 27) - .luminarePlateau() + .font(.callout) + .padding(6) + .frame(height: 27) + .luminarePlateau() - Image(systemName: "plus") - .foregroundStyle(.secondary) + Image(systemName: "plus") + .foregroundStyle(.secondary) + } Keycorder($action) - .opacity(hasConflicts ? 0.5 : 1) + .opacity(hasDuplicateKeybinds || action.keybind.isEmpty ? 0.5 : 1) + } + .contextMenu { + if action.bypassTriggerKey == true { + Button("Link Trigger Key", action: restoreStandardMode) + } else { + Button("Unlink Trigger Key", action: switchToBypassMode) + } + + Button("Clear Keybind", action: clearKeybind) } } } diff --git a/Loop/Updater/Utilities/ChangelogParser.swift b/Loop/Updater/Utilities/ChangelogParser.swift index 09503121..d51365e8 100644 --- a/Loop/Updater/Utilities/ChangelogParser.swift +++ b/Loop/Updater/Utilities/ChangelogParser.swift @@ -114,7 +114,9 @@ enum ChangelogParser { .trimmingCharacters(in: .whitespacesAndNewlines) let emoji = detectEmoji(line: noteText) - let text = noteText.drop { $0.hasEmojiPresentationAsDefault } + let text = noteText + .drop { $0.hasEmojiPresentationAsDefault } + .trimmingCharacters(in: .whitespacesAndNewlines) return ChangelogNote( emoji: emoji, diff --git a/Loop/Window Management/Window Action/WindowAction.swift b/Loop/Window Management/Window Action/WindowAction.swift index eeda2d4f..8d019a9b 100644 --- a/Loop/Window Management/Window Action/WindowAction.swift +++ b/Loop/Window Management/Window Action/WindowAction.swift @@ -42,7 +42,8 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial yPoint: Double? = nil, positionMode: CustomWindowActionPositionMode? = nil, sizeMode: CustomWindowActionSizeMode? = nil, - cycle: [WindowAction]? = nil + cycle: [WindowAction]? = nil, + bypassTriggerKey: Bool? = nil ) { self.id = UUID() self.direction = direction @@ -57,6 +58,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial self.yPoint = yPoint self.sizeMode = sizeMode self.cycle = cycle + self.bypassTriggerKey = bypassTriggerKey } /// Initializes a `WindowAction` with the specified direction and an empty keybind. @@ -94,6 +96,7 @@ struct WindowAction: Codable, Identifiable, Hashable, Equatable, Defaults.Serial // Generic Properties var direction: WindowDirection var keybind: Set + var bypassTriggerKey: Bool? // Custom Keybind Properties var name: String? diff --git a/Loop/Window Management/Window Action/WindowActionCache.swift b/Loop/Window Management/Window Action/WindowActionCache.swift index 790ebf5f..62650101 100644 --- a/Loop/Window Management/Window Action/WindowActionCache.swift +++ b/Loop/Window Management/Window Action/WindowActionCache.swift @@ -13,6 +13,7 @@ import Scribe /// This is called from `KeybindObserver`, to retrieve the user's actions in an efficient manner. final class WindowActionCache { private(set) var actionsByKeybind: [Set: WindowAction] = [:] + private(set) var bypassedActionsByKeybind: [Set: WindowAction] = [:] private(set) var actionsByIdentifier: [UUID: WindowAction] = [:] private var observationTask: Task<(), Never>? @@ -45,26 +46,35 @@ final class WindowActionCache { regenerateActionsByKeybind(from: keybinds) regenerateActionsByIdentifier(from: keybinds) + + Log.info("Regenerated cache; normal: \(actionsByKeybind.count), bypassed: \(bypassedActionsByKeybind.count)", category: .windowActionCache) } private func regenerateActionsByKeybind(from keybinds: [WindowAction]) { let cycleBackwardsOnShiftPressed: Bool = Defaults[.cycleBackwardsOnShiftPressed] + let normalActions = keybinds.filter { $0.bypassTriggerKey != true } + let bypassedActions = keybinds.filter { $0.bypassTriggerKey == true } + + // Normal actions: keybind is action-key only (without trigger key) actionsByKeybind = Dictionary( - keybinds.map { ($0.keybind, $0) }, + normalActions.map { ($0.keybind, $0) }, uniquingKeysWith: { first, _ in first } ) if cycleBackwardsOnShiftPressed { actionsByKeybind.merge( - keybinds + normalActions .filter { $0.direction == .cycle } .map { ($0.keybind.union([.kVK_Shift]), $0) }, uniquingKeysWith: { first, _ in first } ) } - Log.info("Finished regenerating actionsByKeybind", category: .windowActionCache) + bypassedActionsByKeybind = Dictionary( + bypassedActions.map { ($0.keybind, $0) }, + uniquingKeysWith: { first, _ in first } + ) } private func regenerateActionsByIdentifier(from keybinds: [WindowAction]) { @@ -72,7 +82,5 @@ final class WindowActionCache { keybinds.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) - - Log.info("Finished regenerating actionsByIdentifier", category: .windowActionCache) } } diff --git a/Loop/Window Management/Window/WindowUtility.swift b/Loop/Window Management/Window/WindowUtility.swift index 6e9ea0e1..623d37d3 100644 --- a/Loop/Window Management/Window/WindowUtility.swift +++ b/Loop/Window Management/Window/WindowUtility.swift @@ -10,31 +10,28 @@ import Defaults import Scribe /// This enum is in charge of fetching windows in the user's workspace, which will be used by Loop. +@Loggable(style: .static) enum WindowUtility { /// Get the target window, depending on the user's preferences. This could be the frontmost window, or the window under the cursor. /// - Returns: The target window static func userDefinedTargetWindow() -> Window? { var result: Window? - do { - Log.info("Getting window at cursor...", category: .windowUtility) + log.info("Getting window at cursor...") - if Defaults[.resizeWindowUnderCursor], - let mouseLocation = CGEvent.mouseLocation, - let window = try windowAtPosition(mouseLocation) { - result = window - } - } catch { - Log.warn("Failed to get window at cursor: \(error.localizedDescription)", category: .windowUtility) + if Defaults[.resizeWindowUnderCursor], + let mouseLocation = CGEvent.mouseLocation, + let window = windowAtPosition(mouseLocation) { + result = window } if result == nil { do { - Log.info("Getting frontmost window...", category: .windowUtility) + log.info("Getting frontmost window...") result = try frontmostWindow() } catch { - Log.warn("Failed to get frontmost window: \(error.localizedDescription)", category: .windowUtility) + log.warn("Failed to get frontmost window: \(error.localizedDescription)") } } @@ -53,11 +50,15 @@ enum WindowUtility { /// Get the Window at a given position. /// - Parameter position: The position to check for /// - Returns: The window at the given position, if any - static func windowAtPosition(_ position: CGPoint) throws -> Window? { - // If we can find the window at a point using the Accessibility API, return it - if let element = try AXUIElement.systemWide.getElementAtPosition(position), - let windowElement: AXUIElement = try element.getValue(.window) { - return try Window(element: windowElement) + static func windowAtPosition(_ position: CGPoint) -> Window? { + do { + // If we can find the window at a point using the Accessibility API, return it + if let element = try AXUIElement.systemWide.getElementAtPosition(position), + let windowElement: AXUIElement = try element.getValue(.window) { + return try Window(element: windowElement) + } + } catch { + log.warn("Failed to determine element at position: \(error.localizedDescription)") } // If the previous method didn't work, loop through all windows on-screen and return the first one that contains the desired point