From 650642df8aca32b243b3a87bd4b118b59ffe718b Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Mon, 6 Jul 2026 08:54:18 +0200 Subject: [PATCH] Add screenshot release suppression --- README.md | 4 +- Sources/ClickLight/AppDelegate.swift | 35 ++++++++++++- .../ClickLight/ClickCaptureController.swift | 12 +++-- Sources/ClickLight/ClickEvent.swift | 1 + Sources/ClickLight/ClickEventTap.swift | 20 +++++-- .../ClickLight/ClickLightSettingsView.swift | 41 +++++++++++++-- Sources/ClickLight/HotKeyBinding.swift | 4 ++ Sources/ClickLight/SettingsStore.swift | 29 ++++++++++- .../ClickLight/SettingsWindowController.swift | 52 +++++++++++++++++++ 9 files changed, 183 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index bd0d524..472d42b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Prefer not to use Homebrew? Download `ClickLight.zip` from [GitHub Releases](htt - Separate visuals for press, release, right-click, and drag - Optional laser pointer mode with fading freehand strokes while dragging - Optional live keyboard shortcut display pinned to the bottom of the screen by default, with pointer-following placement and sizes through XL available +- Optional screenshot shortcut handling that hides the release highlight after `Command + Shift + 4` - Local daily click activity chart with a resettable seven-day history - Optional daily click count in the menu bar - Dedicated settings window with presets, sliders, optional menu sections, and a sidebar preview pad with Randomize @@ -65,6 +66,7 @@ ClickLight includes one default global shortcut for quick toggles during demos. | Not set by default | Toggle Drag | | Not set by default | Randomize Colors | | Not set by default | Toggle Live Keyboard Shortcuts | +| `Command + Shift + 4` | Hide the next release highlight after screenshot selection | All shortcuts can be changed or disabled in Settings. @@ -74,7 +76,7 @@ ClickLight requires Accessibility permission to detect clicks outside its own me **System Settings -> Privacy & Security -> Accessibility** -The optional Live Keyboard Shortcuts display additionally requires **Input Monitoring**, because macOS protects keyboard input separately from mouse clicks. +The optional Live Keyboard Shortcuts display and screenshot shortcut handling additionally require **Input Monitoring**, because macOS protects keyboard input separately from mouse clicks. After enabling permission, quit ClickLight from the menu bar and reopen it. diff --git a/Sources/ClickLight/AppDelegate.swift b/Sources/ClickLight/AppDelegate.swift index c74d79a..ecef9fd 100644 --- a/Sources/ClickLight/AppDelegate.swift +++ b/Sources/ClickLight/AppDelegate.swift @@ -36,17 +36,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var captureEnabledState: Bool? private var laserPointerEnabledState: Bool? private var liveKeyboardShortcutsEnabledState: Bool? + private var releaseSuppressionShortcutEnabledState: Bool? + private var suppressReleaseUntil: TimeInterval? private var hotKeyBindingsState: [ClickShortcutAction: HotKeyBinding] = [:] private var hotKeyRegistrationIssuesState: [ClickShortcutAction: String] = [:] private var activeShortcutRecorders = 0 + private let releaseSuppressionTimeout: TimeInterval = 10 func applicationDidFinishLaunching(_ notification: Notification) { configureMainMenu() overlayCoordinator.start() permissions.requestAccessibilityIfNeeded() + if settingsStore.settings.showLiveKeyboardShortcuts || settingsStore.settings.listensForReleaseSuppressionShortcut { + permissions.requestInputMonitoringIfNeeded() + } captureEnabledState = settingsStore.settings.isEnabled laserPointerEnabledState = settingsStore.settings.showLaserPointer liveKeyboardShortcutsEnabledState = settingsStore.settings.showLiveKeyboardShortcuts + releaseSuppressionShortcutEnabledState = settingsStore.settings.listensForReleaseSuppressionShortcut captureController.startIfEnabled() statusController.start() configureHotKeysIfNeeded(with: settingsStore.settings, force: true) @@ -114,17 +121,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if settings.showLiveKeyboardShortcuts && liveKeyboardShortcutsEnabledState != true { permissions.requestInputMonitoringIfNeeded() } + if settings.listensForReleaseSuppressionShortcut && releaseSuppressionShortcutEnabledState != true { + permissions.requestInputMonitoringIfNeeded() + } overlayCoordinator.refreshSettings() configureHotKeysIfNeeded(with: settings) let isEnabled = settings.isEnabled let laserPointerEnabled = settings.showLaserPointer let liveKeyboardShortcutsEnabled = settings.showLiveKeyboardShortcuts + let releaseSuppressionShortcutEnabled = settings.listensForReleaseSuppressionShortcut guard captureEnabledState != isEnabled || laserPointerEnabledState != laserPointerEnabled || - liveKeyboardShortcutsEnabledState != liveKeyboardShortcutsEnabled else { return } + liveKeyboardShortcutsEnabledState != liveKeyboardShortcutsEnabled || + releaseSuppressionShortcutEnabledState != releaseSuppressionShortcutEnabled else { return } captureEnabledState = isEnabled laserPointerEnabledState = laserPointerEnabled liveKeyboardShortcutsEnabledState = liveKeyboardShortcutsEnabled + releaseSuppressionShortcutEnabledState = releaseSuppressionShortcutEnabled captureController.refreshEnabledState() } @@ -201,15 +214,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func clickEventDidArrive(_ notification: Notification) { guard let box = notification.object as? ClickEventBox else { return } guard settingsWindowController?.contains(box.event.location) != true else { return } + guard !shouldSuppressRelease(box.event) else { return } activityStore.record(box.event) overlayCoordinator.show(box.event) } @objc private func keyboardShortcutEventDidArrive(_ notification: Notification) { guard let box = notification.object as? KeyboardShortcutEventBox else { return } + if shouldArmReleaseSuppression(for: box.event) { + suppressReleaseUntil = ProcessInfo.processInfo.systemUptime + releaseSuppressionTimeout + } overlayCoordinator.show(box.event) } + private func shouldArmReleaseSuppression(for event: KeyboardShortcutEvent) -> Bool { + let settings = settingsStore.settings + guard settings.suppressReleaseAfterShortcut else { return false } + return settings.releaseSuppressionHotKey == event.binding + } + + private func shouldSuppressRelease(_ event: ClickEvent) -> Bool { + guard event.kind.isRelease, let suppressReleaseUntil else { return false } + + defer { + self.suppressReleaseUntil = nil + } + + return ProcessInfo.processInfo.systemUptime <= suppressReleaseUntil + } + private func configureMainMenu() { let mainMenu = NSMenu() let appMenuItem = NSMenuItem() diff --git a/Sources/ClickLight/ClickCaptureController.swift b/Sources/ClickLight/ClickCaptureController.swift index c8f2e74..0ee1340 100644 --- a/Sources/ClickLight/ClickCaptureController.swift +++ b/Sources/ClickLight/ClickCaptureController.swift @@ -3,7 +3,11 @@ import Foundation protocol ClickEventCapturing: AnyObject { var statusLabel: String { get } - func start(laserPointerEnabled: Bool, liveKeyboardShortcutsEnabled: Bool) + func start( + laserPointerEnabled: Bool, + liveKeyboardShortcutsEnabled: Bool, + releaseSuppressionShortcutEnabled: Bool + ) func stop() } @@ -25,7 +29,8 @@ final class ClickCaptureController { guard settingsStore.settings.isEnabled else { return } eventTap.start( laserPointerEnabled: settingsStore.settings.showLaserPointer, - liveKeyboardShortcutsEnabled: settingsStore.settings.showLiveKeyboardShortcuts + liveKeyboardShortcutsEnabled: settingsStore.settings.showLiveKeyboardShortcuts, + releaseSuppressionShortcutEnabled: settingsStore.settings.listensForReleaseSuppressionShortcut ) } @@ -33,7 +38,8 @@ final class ClickCaptureController { if settingsStore.settings.isEnabled { eventTap.start( laserPointerEnabled: settingsStore.settings.showLaserPointer, - liveKeyboardShortcutsEnabled: settingsStore.settings.showLiveKeyboardShortcuts + liveKeyboardShortcutsEnabled: settingsStore.settings.showLiveKeyboardShortcuts, + releaseSuppressionShortcutEnabled: settingsStore.settings.listensForReleaseSuppressionShortcut ) } else { eventTap.stop() diff --git a/Sources/ClickLight/ClickEvent.swift b/Sources/ClickLight/ClickEvent.swift index 9a4d44d..b55048b 100644 --- a/Sources/ClickLight/ClickEvent.swift +++ b/Sources/ClickLight/ClickEvent.swift @@ -22,6 +22,7 @@ struct ClickEvent: Sendable { } struct KeyboardShortcutEvent: Sendable { + let binding: HotKeyBinding let displayString: String let location: CGPoint let timestamp: TimeInterval diff --git a/Sources/ClickLight/ClickEventTap.swift b/Sources/ClickLight/ClickEventTap.swift index 1e90d90..507463c 100644 --- a/Sources/ClickLight/ClickEventTap.swift +++ b/Sources/ClickLight/ClickEventTap.swift @@ -9,6 +9,7 @@ final class ClickEventTap: ClickEventCapturing { private var globalMonitor: Any? private var laserPointerEnabled = false private var liveKeyboardShortcutsEnabled = false + private var releaseSuppressionShortcutEnabled = false var statusLabel: String { switch (eventTap != nil, globalMonitor != nil) { @@ -23,12 +24,18 @@ final class ClickEventTap: ClickEventCapturing { } } - func start(laserPointerEnabled: Bool, liveKeyboardShortcutsEnabled: Bool) { + func start( + laserPointerEnabled: Bool, + liveKeyboardShortcutsEnabled: Bool, + releaseSuppressionShortcutEnabled: Bool + ) { if self.laserPointerEnabled != laserPointerEnabled || - self.liveKeyboardShortcutsEnabled != liveKeyboardShortcutsEnabled { + self.liveKeyboardShortcutsEnabled != liveKeyboardShortcutsEnabled || + self.releaseSuppressionShortcutEnabled != releaseSuppressionShortcutEnabled { stop() self.laserPointerEnabled = laserPointerEnabled self.liveKeyboardShortcutsEnabled = liveKeyboardShortcutsEnabled + self.releaseSuppressionShortcutEnabled = releaseSuppressionShortcutEnabled } startEventTapIfNeeded() startGlobalMonitorIfNeeded() @@ -56,7 +63,7 @@ final class ClickEventTap: ClickEventCapturing { if laserPointerEnabled { types.append(.mouseMoved) } - if liveKeyboardShortcutsEnabled { + if shouldObserveKeyboardShortcuts { types.append(.keyDown) } let mask = types.reduce(CGEventMask(0)) { partial, eventType in @@ -114,7 +121,7 @@ final class ClickEventTap: ClickEventCapturing { if laserPointerEnabled { eventTypes.insert(.mouseMoved) } - if liveKeyboardShortcutsEnabled { + if shouldObserveKeyboardShortcuts { eventTypes.insert(.keyDown) } @@ -171,6 +178,7 @@ final class ClickEventTap: ClickEventCapturing { private static func post(shortcut: HotKeyBinding, timestamp: TimeInterval) { DispatchQueue.main.async { let shortcutEvent = KeyboardShortcutEvent( + binding: shortcut, displayString: shortcut.displayString, location: NSEvent.mouseLocation, timestamp: timestamp @@ -193,6 +201,10 @@ final class ClickEventTap: ClickEventCapturing { guard shortcut.keyString != "?" else { return nil } return shortcut } + + private var shouldObserveKeyboardShortcuts: Bool { + liveKeyboardShortcutsEnabled || releaseSuppressionShortcutEnabled + } } private func eventTapCallback( diff --git a/Sources/ClickLight/ClickLightSettingsView.swift b/Sources/ClickLight/ClickLightSettingsView.swift index 4590b3a..3215534 100644 --- a/Sources/ClickLight/ClickLightSettingsView.swift +++ b/Sources/ClickLight/ClickLightSettingsView.swift @@ -233,7 +233,7 @@ struct ClickLightSettingsView: View { } } - if viewModel.settings.showLiveKeyboardShortcuts { + if viewModel.settings.showLiveKeyboardShortcuts || viewModel.settings.listensForReleaseSuppressionShortcut { SettingsCard { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { @@ -246,7 +246,7 @@ struct ClickLightSettingsView: View { .font(.callout.weight(.medium)) Text(viewModel.inputMonitoringTrusted ? "ClickLight can observe keyboard shortcuts across the system." - : "Grant Input Monitoring access so ClickLight can show keyboard shortcuts.") + : "Grant Input Monitoring access so ClickLight can observe keyboard shortcuts.") .font(.caption) .foregroundStyle(.secondary) } @@ -638,9 +638,42 @@ struct ClickLightSettingsView: View { } } + SettingsCard(title: "Screenshot Capture") { + VStack(spacing: 0) { + ModernRow(title: "Hide Release After Shortcut", + subtitle: "Skip one release highlight after the screenshot shortcut.") { + Toggle("", isOn: binding(\.suppressReleaseAfterShortcut)) + .toggleStyle(.switch) + .labelsHidden() + .accessibilityLabel("Hide Release After Shortcut") + } + + Divider().padding(.vertical, 4) + + ShortcutRecorderField( + label: "Screenshot Shortcut", + currentBinding: viewModel.settings.releaseSuppressionHotKey, + defaultBinding: HotKeyBinding.defaultScreenshotReleaseSuppression, + errorMessage: viewModel.releaseSuppressionShortcutError, + onRecord: { binding in + viewModel.updateReleaseSuppressionShortcutBinding(binding) + }, + onReset: { + viewModel.resetReleaseSuppressionShortcutBinding() + }, + onClear: { + viewModel.clearReleaseSuppressionShortcutBinding() + } + ) + .padding(.vertical, 4) + .disabled(!viewModel.settings.suppressReleaseAfterShortcut) + .opacity(viewModel.settings.suppressReleaseAfterShortcut ? 1 : 0.55) + } + } + SettingsCard { ModernRow(title: "Reset All Shortcuts", - subtitle: "Restore the ClickLight toggle shortcut and disable optional shortcuts.") { + subtitle: "Restore default shortcuts and disable optional shortcuts.") { Button(role: .destructive) { showShortcutResetConfirmation = true } label: { @@ -661,7 +694,7 @@ struct ClickLightSettingsView: View { } Button("Cancel", role: .cancel) {} } message: { - Text("This restores the ClickLight toggle shortcut and disables every optional shortcut.") + Text("This restores default shortcuts and disables every optional shortcut.") } } diff --git a/Sources/ClickLight/HotKeyBinding.swift b/Sources/ClickLight/HotKeyBinding.swift index f46141d..15fc8d1 100644 --- a/Sources/ClickLight/HotKeyBinding.swift +++ b/Sources/ClickLight/HotKeyBinding.swift @@ -6,6 +6,10 @@ struct HotKeyBinding: Equatable, Hashable, Sendable { let carbonModifiers: Int static let defaultToggleModifiers: Int = Int(controlKey | optionKey | cmdKey) + static let defaultScreenshotReleaseSuppression = HotKeyBinding( + keyCode: kVK_ANSI_4, + carbonModifiers: Int(cmdKey | shiftKey) + ) var displayString: String { modifiersString + keyString diff --git a/Sources/ClickLight/SettingsStore.swift b/Sources/ClickLight/SettingsStore.swift index e7f6dd9..1683373 100644 --- a/Sources/ClickLight/SettingsStore.swift +++ b/Sources/ClickLight/SettingsStore.swift @@ -10,6 +10,7 @@ struct ClickSettings: Equatable { var showLaserPointer: Bool var showArrowMode: Bool var showLiveKeyboardShortcuts: Bool + var suppressReleaseAfterShortcut: Bool var liveShortcutPosition: LiveShortcutPosition var liveShortcutSize: LiveShortcutSize var showEventControlsInMenu: Bool @@ -56,6 +57,7 @@ struct ClickSettings: Equatable { var toggleShowDragHotKey: HotKeyBinding? var randomizeColorsHotKey: HotKeyBinding? var toggleLiveKeyboardShortcutsHotKey: HotKeyBinding? + var releaseSuppressionHotKey: HotKeyBinding? var customColor: NSColor { NSColor( @@ -139,6 +141,7 @@ struct ClickSettings: Equatable { showLaserPointer: false, showArrowMode: false, showLiveKeyboardShortcuts: false, + suppressReleaseAfterShortcut: true, liveShortcutPosition: .bottomCenter, liveShortcutSize: .medium, showEventControlsInMenu: true, @@ -184,9 +187,14 @@ struct ClickSettings: Equatable { toggleShowMiddleClickHotKey: ClickShortcutAction.toggleShowMiddleClick.defaultBinding, toggleShowDragHotKey: ClickShortcutAction.toggleShowDrag.defaultBinding, randomizeColorsHotKey: ClickShortcutAction.randomizeColors.defaultBinding, - toggleLiveKeyboardShortcutsHotKey: ClickShortcutAction.toggleLiveKeyboardShortcuts.defaultBinding + toggleLiveKeyboardShortcutsHotKey: ClickShortcutAction.toggleLiveKeyboardShortcuts.defaultBinding, + releaseSuppressionHotKey: HotKeyBinding.defaultScreenshotReleaseSuppression ) + var listensForReleaseSuppressionShortcut: Bool { + suppressReleaseAfterShortcut && releaseSuppressionHotKey != nil + } + var shortcutBindings: [ClickShortcutAction: HotKeyBinding] { Dictionary(uniqueKeysWithValues: ClickShortcutAction.allCases.compactMap { action in shortcutBinding(for: action).map { (action, $0) } @@ -259,6 +267,7 @@ struct ClickSettings: Equatable { for action in ClickShortcutAction.allCases { resetShortcutBinding(for: action) } + releaseSuppressionHotKey = HotKeyBinding.defaultScreenshotReleaseSuppression } static func defaultShortcutBinding(for action: ClickShortcutAction) -> HotKeyBinding? { @@ -469,6 +478,7 @@ final class SettingsStore { static let showLaserPointer = "showLaserPointer" static let showArrowMode = "showArrowMode" static let showLiveKeyboardShortcuts = "showLiveKeyboardShortcuts" + static let suppressReleaseAfterShortcut = "suppressReleaseAfterShortcut" static let liveShortcutPosition = "liveShortcutPosition" static let liveShortcutSize = "liveShortcutSize" static let showMenuBarText = "showMenuBarText" @@ -537,6 +547,9 @@ final class SettingsStore { static let toggleLiveKeyboardShortcutsHotKeyCode = "toggleLiveKeyboardShortcutsHotKeyCode" static let toggleLiveKeyboardShortcutsHotKeyModifiers = "toggleLiveKeyboardShortcutsHotKeyModifiers" static let toggleLiveKeyboardShortcutsHotKeyIsEnabled = "toggleLiveKeyboardShortcutsHotKeyIsEnabled" + static let releaseSuppressionHotKeyCode = "releaseSuppressionHotKeyCode" + static let releaseSuppressionHotKeyModifiers = "releaseSuppressionHotKeyModifiers" + static let releaseSuppressionHotKeyIsEnabled = "releaseSuppressionHotKeyIsEnabled" } private let defaults: UserDefaults @@ -558,6 +571,7 @@ final class SettingsStore { showLaserPointer: defaults.bool(forKey: Key.showLaserPointer), showArrowMode: defaults.bool(forKey: Key.showArrowMode), showLiveKeyboardShortcuts: defaults.bool(forKey: Key.showLiveKeyboardShortcuts), + suppressReleaseAfterShortcut: defaults.bool(forKey: Key.suppressReleaseAfterShortcut), liveShortcutPosition: LiveShortcutPosition(rawValue: defaults.string(forKey: Key.liveShortcutPosition) ?? "") ?? .bottomCenter, liveShortcutSize: LiveShortcutSize(rawValue: defaults.string(forKey: Key.liveShortcutSize) ?? "") ?? .medium, showEventControlsInMenu: defaults.bool(forKey: Key.showEventControlsInMenu), @@ -647,6 +661,11 @@ final class SettingsStore { keyCode: Key.toggleLiveKeyboardShortcutsHotKeyCode, modifiers: Key.toggleLiveKeyboardShortcutsHotKeyModifiers, isEnabled: Key.toggleLiveKeyboardShortcutsHotKeyIsEnabled + ), + releaseSuppressionHotKey: shortcutBinding( + keyCode: Key.releaseSuppressionHotKeyCode, + modifiers: Key.releaseSuppressionHotKeyModifiers, + isEnabled: Key.releaseSuppressionHotKeyIsEnabled ) ) } @@ -660,6 +679,7 @@ final class SettingsStore { defaults.set(newValue.showLaserPointer, forKey: Key.showLaserPointer) defaults.set(newValue.showArrowMode, forKey: Key.showArrowMode) defaults.set(newValue.showLiveKeyboardShortcuts, forKey: Key.showLiveKeyboardShortcuts) + defaults.set(newValue.suppressReleaseAfterShortcut, forKey: Key.suppressReleaseAfterShortcut) defaults.set(newValue.liveShortcutPosition.rawValue, forKey: Key.liveShortcutPosition) defaults.set(newValue.liveShortcutSize.rawValue, forKey: Key.liveShortcutSize) defaults.set(newValue.showEventControlsInMenu, forKey: Key.showEventControlsInMenu) @@ -706,6 +726,7 @@ final class SettingsStore { saveShortcutBinding(newValue.toggleShowDragHotKey, keyCode: Key.toggleShowDragHotKeyCode, modifiers: Key.toggleShowDragHotKeyModifiers, isEnabled: Key.toggleShowDragHotKeyIsEnabled) saveShortcutBinding(newValue.randomizeColorsHotKey, keyCode: Key.randomizeColorsHotKeyCode, modifiers: Key.randomizeColorsHotKeyModifiers, isEnabled: Key.randomizeColorsHotKeyIsEnabled) saveShortcutBinding(newValue.toggleLiveKeyboardShortcutsHotKey, keyCode: Key.toggleLiveKeyboardShortcutsHotKeyCode, modifiers: Key.toggleLiveKeyboardShortcutsHotKeyModifiers, isEnabled: Key.toggleLiveKeyboardShortcutsHotKeyIsEnabled) + saveShortcutBinding(newValue.releaseSuppressionHotKey, keyCode: Key.releaseSuppressionHotKeyCode, modifiers: Key.releaseSuppressionHotKeyModifiers, isEnabled: Key.releaseSuppressionHotKeyIsEnabled) NotificationCenter.default.post(name: Self.didChangeNotification, object: self) } } @@ -743,6 +764,7 @@ final class SettingsStore { Key.showLaserPointer: defaults.showLaserPointer, Key.showArrowMode: defaults.showArrowMode, Key.showLiveKeyboardShortcuts: defaults.showLiveKeyboardShortcuts, + Key.suppressReleaseAfterShortcut: defaults.suppressReleaseAfterShortcut, Key.liveShortcutPosition: defaults.liveShortcutPosition.rawValue, Key.liveShortcutSize: defaults.liveShortcutSize.rawValue, Key.showEventControlsInMenu: defaults.showEventControlsInMenu, @@ -810,7 +832,10 @@ final class SettingsStore { Key.randomizeColorsHotKeyIsEnabled: false, Key.toggleLiveKeyboardShortcutsHotKeyCode: 0, Key.toggleLiveKeyboardShortcutsHotKeyModifiers: 0, - Key.toggleLiveKeyboardShortcutsHotKeyIsEnabled: false + Key.toggleLiveKeyboardShortcutsHotKeyIsEnabled: false, + Key.releaseSuppressionHotKeyCode: HotKeyBinding.defaultScreenshotReleaseSuppression.keyCode, + Key.releaseSuppressionHotKeyModifiers: HotKeyBinding.defaultScreenshotReleaseSuppression.carbonModifiers, + Key.releaseSuppressionHotKeyIsEnabled: true ]) } } diff --git a/Sources/ClickLight/SettingsWindowController.swift b/Sources/ClickLight/SettingsWindowController.swift index d0d0f2a..c956494 100644 --- a/Sources/ClickLight/SettingsWindowController.swift +++ b/Sources/ClickLight/SettingsWindowController.swift @@ -75,6 +75,7 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { @Published var launchAtLoginErrorMessage: String? @Published var selectedPane: SettingsPane = .general @Published private(set) var shortcutErrors: [ClickShortcutAction: String] = [:] + @Published private(set) var releaseSuppressionShortcutError: String? @Published private(set) var hotKeyRegistrationIssues: [ClickShortcutAction: String] = [:] init( @@ -93,6 +94,7 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { self.accessibilityTrusted = permissions.isAccessibilityTrusted self.inputMonitoringTrusted = permissions.isInputMonitoringTrusted self.shortcutErrors = Self.findShortcutConflicts(in: settings) + self.releaseSuppressionShortcutError = Self.findReleaseSuppressionShortcutConflict(in: settings) self.hotKeyRegistrationIssues = hotKeyRegistrationIssuesProvider() NotificationCenter.default.addObserver( self, @@ -124,6 +126,7 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { settings = latestSettings } shortcutErrors = Self.findShortcutConflicts(in: latestSettings) + releaseSuppressionShortcutError = Self.findReleaseSuppressionShortcutConflict(in: latestSettings) } @objc private func appBecameActive() { @@ -307,6 +310,10 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { shortcutErrors[action] = message return false } + if settings.releaseSuppressionHotKey == binding { + shortcutErrors[action] = "Matches Screenshot Shortcut. Choose a unique shortcut." + return false + } shortcutErrors[action] = nil update { settings in @@ -334,10 +341,38 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { } } + @discardableResult + func updateReleaseSuppressionShortcutBinding(_ binding: HotKeyBinding) -> Bool { + if let conflictingAction = conflictAction(for: binding) { + releaseSuppressionShortcutError = "Matches \(conflictingAction.title). Choose a unique shortcut." + return false + } + + releaseSuppressionShortcutError = nil + update { settings in + settings.releaseSuppressionHotKey = binding + } + return true + } + + func resetReleaseSuppressionShortcutBinding() { + update { settings in + settings.releaseSuppressionHotKey = HotKeyBinding.defaultScreenshotReleaseSuppression + } + } + + func clearReleaseSuppressionShortcutBinding() { + releaseSuppressionShortcutError = nil + update { settings in + settings.releaseSuppressionHotKey = nil + } + } + private func apply(_ updatedSettings: ClickSettings) { guard settings != updatedSettings else { return } settings = updatedSettings shortcutErrors = Self.findShortcutConflicts(in: updatedSettings) + releaseSuppressionShortcutError = Self.findReleaseSuppressionShortcutConflict(in: updatedSettings) DispatchQueue.main.async { [weak self] in guard let self, self.settings == updatedSettings else { return } @@ -352,6 +387,12 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { } } + private func conflictAction(for binding: HotKeyBinding) -> ClickShortcutAction? { + ClickShortcutAction.allCases.first { action in + settings.shortcutBinding(for: action) == binding + } + } + private static func findShortcutConflicts(in settings: ClickSettings) -> [ClickShortcutAction: String] { var errors: [ClickShortcutAction: String] = [:] @@ -360,6 +401,9 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { guard let other = ClickShortcutAction.allCases.first(where: { $0 != action && settings.shortcutBinding(for: $0) == binding }) else { + if settings.releaseSuppressionHotKey == binding { + errors[action] = "Matches Screenshot Shortcut. Choose a unique shortcut." + } continue } @@ -368,4 +412,12 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { return errors } + + private static func findReleaseSuppressionShortcutConflict(in settings: ClickSettings) -> String? { + guard let binding = settings.releaseSuppressionHotKey else { return nil } + guard let action = ClickShortcutAction.allCases.first(where: { settings.shortcutBinding(for: $0) == binding }) else { + return nil + } + return "Matches \(action.title). Choose a unique shortcut." + } }