Skip to content
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand Down
35 changes: 34 additions & 1 deletion Sources/ClickLight/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment on lines +44 to 45
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)
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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)
}
Comment on lines 222 to 228

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()
Expand Down
12 changes: 9 additions & 3 deletions Sources/ClickLight/ClickCaptureController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -25,15 +29,17 @@ 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
)
}

func refreshEnabledState() {
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()
Expand Down
1 change: 1 addition & 0 deletions Sources/ClickLight/ClickEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ struct ClickEvent: Sendable {
}

struct KeyboardShortcutEvent: Sendable {
let binding: HotKeyBinding
let displayString: String
let location: CGPoint
let timestamp: TimeInterval
Expand Down
20 changes: 16 additions & 4 deletions Sources/ClickLight/ClickEventTap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -114,7 +121,7 @@ final class ClickEventTap: ClickEventCapturing {
if laserPointerEnabled {
eventTypes.insert(.mouseMoved)
}
if liveKeyboardShortcutsEnabled {
if shouldObserveKeyboardShortcuts {
eventTypes.insert(.keyDown)
}

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
41 changes: 37 additions & 4 deletions Sources/ClickLight/ClickLightSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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: {
Expand All @@ -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.")
}
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/ClickLight/HotKeyBinding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading