diff --git a/Sources/ClickLight/AppDelegate.swift b/Sources/ClickLight/AppDelegate.swift index 24df72e..c74d79a 100644 --- a/Sources/ClickLight/AppDelegate.swift +++ b/Sources/ClickLight/AppDelegate.swift @@ -20,6 +20,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { onCheckForUpdates: { UpdateChecker.shared.checkForUpdates() }, updatesAreConfigured: { UpdateChecker.shared.isConfigured }, onOpenSettings: { [weak self] pane in self?.openSettings(selecting: pane) }, + onClearArrows: { [weak self] in self?.overlayCoordinator.clearArrows() }, onQuit: { NSApplication.shared.terminate(nil) }, onMenuWillOpen: { [weak self] in self?.hotKeyManager.unregisterAll() @@ -158,9 +159,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { statusController.dismissMenu() switch action { case .toggleEnabled: - settingsStore.update { $0.isEnabled.toggle() } + settingsStore.update { + $0.isEnabled.toggle() + if !$0.isEnabled { + $0.showArrowMode = false + } + } case .toggleLaserPointer: - settingsStore.update { $0.showLaserPointer.toggle() } + settingsStore.update { + $0.showLaserPointer.toggle() + if $0.showLaserPointer { + $0.showArrowMode = false + } + } + case .toggleArrowMode: + settingsStore.update { + $0.showArrowMode.toggle() + if $0.showArrowMode { + $0.showLaserPointer = false + } + } + case .clearArrows: + overlayCoordinator.clearArrows() case .toggleShowPress: settingsStore.update { $0.showPress.toggle() } case .toggleShowRelease: diff --git a/Sources/ClickLight/ClickLightSettingsView.swift b/Sources/ClickLight/ClickLightSettingsView.swift index 0e81026..4590b3a 100644 --- a/Sources/ClickLight/ClickLightSettingsView.swift +++ b/Sources/ClickLight/ClickLightSettingsView.swift @@ -488,12 +488,40 @@ struct ClickLightSettingsView: View { VStack(spacing: 0) { ModernRow(title: "Laser Pointer Mode", subtitle: "Show a fading pointer and draw temporary strokes while dragging.") { - Toggle("", isOn: binding(\.showLaserPointer)) + Toggle("", isOn: Binding( + get: { viewModel.settings.showLaserPointer }, + set: { isEnabled in + viewModel.update { + $0.showLaserPointer = isEnabled + if isEnabled { + $0.showArrowMode = false + } + } + } + )) .toggleStyle(.switch) .labelsHidden() .accessibilityLabel("Laser Pointer Mode") } Divider().padding(.vertical, 6) + ModernRow(title: "Arrow Mode", + subtitle: "Drag from origin to arrow tip. Clear arrows when you're done.") { + Toggle("", isOn: Binding( + get: { viewModel.settings.showArrowMode }, + set: { isEnabled in + viewModel.update { + $0.showArrowMode = isEnabled + if isEnabled { + $0.showLaserPointer = false + } + } + } + )) + .toggleStyle(.switch) + .labelsHidden() + .accessibilityLabel("Arrow Mode") + } + Divider().padding(.vertical, 6) ModernRow(title: "Show Live Keyboard Shortcuts", subtitle: "Display shortcut combinations while you use them.") { Toggle("", isOn: binding(\.showLiveKeyboardShortcuts)) @@ -551,14 +579,14 @@ struct ClickLightSettingsView: View { } Divider().padding(.vertical, 6) ModernRow(title: "Show Drag", - subtitle: viewModel.settings.showLaserPointer - ? "Laser Pointer Mode replaces the normal drag trail." + subtitle: viewModel.settings.showLaserPointer || viewModel.settings.showArrowMode + ? "Laser Pointer Mode and Arrow Mode replace the normal drag trail." : "Trail pointer movement while dragging.") { Toggle("", isOn: binding(\.showDrag)) .toggleStyle(.switch) .labelsHidden() .accessibilityLabel("Show Drag") - .disabled(viewModel.settings.showLaserPointer) + .disabled(viewModel.settings.showLaserPointer || viewModel.settings.showArrowMode) } } } diff --git a/Sources/ClickLight/ClickOverlayView.swift b/Sources/ClickLight/ClickOverlayView.swift index 43e4db1..5127628 100644 --- a/Sources/ClickLight/ClickOverlayView.swift +++ b/Sources/ClickLight/ClickOverlayView.swift @@ -7,6 +7,8 @@ final class ClickOverlayView: NSView { private var laserCursor: LaserCursor? private var activeLaserStroke: LaserStroke? private var completedLaserStrokes: [LaserStroke] = [] + private var activeArrow: ArrowAnnotation? + private var completedArrows: [ArrowAnnotation] = [] private var liveShortcutLabel: LiveShortcutLabel? private var displayLink: Timer? @@ -30,6 +32,11 @@ final class ClickOverlayView: NSView { completedLaserStrokes = [] needsDisplay = true } + if !settings.isEnabled || !settings.showArrowMode { + activeArrow = nil + completedArrows = [] + needsDisplay = true + } if !settings.showLiveKeyboardShortcuts { liveShortcutLabel = nil needsDisplay = true @@ -59,6 +66,20 @@ final class ClickOverlayView: NSView { } } + if settings.showArrowMode { + switch event.kind { + case .leftDown, .rightDown, .middleDown: + startArrow(at: localPoint) + case .drag: + updateArrow(to: localPoint) + return + case .leftUp, .rightUp, .middleUp: + completeArrow(at: localPoint) + case .move: + break + } + } + guard shouldShowPulse(for: event.kind) else { return } pulses.append(ClickPulse( @@ -89,6 +110,12 @@ final class ClickOverlayView: NSView { needsDisplay = true } + func clearArrows() { + activeArrow = nil + completedArrows = [] + needsDisplay = true + } + override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) guard let context = NSGraphicsContext.current?.cgContext else { return } @@ -101,6 +128,7 @@ final class ClickOverlayView: NSView { } drawLaser(at: now, in: context) + drawArrows(in: context) for pulse in pulses { draw(pulse: pulse, at: now, in: context) } @@ -110,7 +138,9 @@ final class ClickOverlayView: NSView { laserCursor?.isExpired(at: now) != false && activeLaserStroke == nil && completedLaserStrokes.isEmpty && + activeArrow == nil && liveShortcutLabel == nil { + // Completed arrows are static and persist until cleared, so they do not need the display link. stopDisplayLink() } } @@ -149,6 +179,35 @@ final class ClickOverlayView: NSView { needsDisplay = true } + private func startArrow(at point: CGPoint) { + activeArrow = ArrowAnnotation(start: point, end: point) + startDisplayLink() + needsDisplay = true + } + + private func updateArrow(to point: CGPoint) { + if activeArrow == nil { + activeArrow = ArrowAnnotation(start: point, end: point) + } else { + activeArrow?.end = point + } + startDisplayLink() + needsDisplay = true + } + + private func completeArrow(at point: CGPoint) { + guard var arrow = activeArrow else { return } + arrow.end = point + activeArrow = nil + guard arrow.length >= 18 else { + needsDisplay = true + return + } + completedArrows.append(arrow) + startDisplayLink() + needsDisplay = true + } + private func drawLaser(at now: CFTimeInterval, in context: CGContext) { guard settings.showLaserPointer else { return } @@ -214,6 +273,77 @@ final class ClickOverlayView: NSView { context.restoreGState() } + private func drawArrows(in context: CGContext) { + guard settings.showArrowMode else { return } + + for arrow in completedArrows { + drawArrow(arrow, alpha: 1, in: context) + } + + if let activeArrow { + drawArrow(activeArrow, alpha: 0.96, in: context) + } + } + + private func drawArrow(_ arrow: ArrowAnnotation, alpha: CGFloat, in context: CGContext) { + guard arrow.length >= 6, alpha > 0 else { return } + + let vector = CGPoint(x: arrow.end.x - arrow.start.x, y: arrow.end.y - arrow.start.y) + let length = max(arrow.length, 1) + let unit = CGPoint(x: vector.x / length, y: vector.y / length) + let normal = CGPoint(x: -unit.y, y: unit.x) + let headLength = min(max(settings.size * 0.58, 30), max(30, length * 0.42)) + let headWidth = max(settings.size * 0.36, 22) + let shaftEnd = CGPoint( + x: arrow.end.x - unit.x * headLength * 0.72, + y: arrow.end.y - unit.y * headLength * 0.72 + ) + let left = CGPoint( + x: arrow.end.x - unit.x * headLength + normal.x * headWidth * 0.5, + y: arrow.end.y - unit.y * headLength + normal.y * headWidth * 0.5 + ) + let right = CGPoint( + x: arrow.end.x - unit.x * headLength - normal.x * headWidth * 0.5, + y: arrow.end.y - unit.y * headLength - normal.y * headWidth * 0.5 + ) + let mainWidth = max(settings.size * 0.14, 8) + let arrowColor = settings.laserColor + + context.saveGState() + context.setLineCap(.round) + context.setLineJoin(.round) + + context.move(to: arrow.start) + context.addLine(to: shaftEnd) + context.setStrokeColor(NSColor.black.withAlphaComponent(alpha * 0.24).cgColor) + context.setLineWidth(mainWidth + 3) + context.strokePath() + + context.move(to: arrow.start) + context.addLine(to: shaftEnd) + context.setStrokeColor(arrowColor.withAlphaComponent(alpha).cgColor) + context.setLineWidth(mainWidth) + context.strokePath() + + let headPath = CGMutablePath() + headPath.move(to: arrow.end) + headPath.addLine(to: left) + headPath.addLine(to: right) + headPath.closeSubpath() + + context.addPath(headPath) + context.setFillColor(NSColor.black.withAlphaComponent(alpha * 0.24).cgColor) + context.setShadow(offset: CGSize(width: 0, height: -1), blur: 3, color: NSColor.black.withAlphaComponent(alpha * 0.18).cgColor) + context.fillPath() + + context.setShadow(offset: .zero, blur: 0, color: nil) + context.addPath(headPath) + context.setFillColor(arrowColor.withAlphaComponent(alpha).cgColor) + context.fillPath() + + context.restoreGState() + } + private func drawLiveShortcutLabel(at now: CFTimeInterval, in context: CGContext) { guard settings.showLiveKeyboardShortcuts, let label = liveShortcutLabel else { return } @@ -550,7 +680,7 @@ final class ClickOverlayView: NSView { case .middleUp: return settings.showMiddleClick && settings.showRelease case .drag: - return settings.showDrag && !settings.showLaserPointer + return settings.showDrag && !settings.showLaserPointer && !settings.showArrowMode case .move: return false } @@ -633,3 +763,12 @@ private struct LaserStroke { return time - completedAt >= Self.fadeDuration } } + +private struct ArrowAnnotation { + let start: CGPoint + var end: CGPoint + + var length: CGFloat { + hypot(end.x - start.x, end.y - start.y) + } +} diff --git a/Sources/ClickLight/ClickOverlayWindow.swift b/Sources/ClickLight/ClickOverlayWindow.swift index 0ddfde6..dff7162 100644 --- a/Sources/ClickLight/ClickOverlayWindow.swift +++ b/Sources/ClickLight/ClickOverlayWindow.swift @@ -41,4 +41,8 @@ final class ClickOverlayWindow: NSWindow { orderFrontRegardless() overlayView.show(shortcut: shortcut, settings: settings) } + + func clearArrows() { + overlayView.clearArrows() + } } diff --git a/Sources/ClickLight/ClickProfileStore.swift b/Sources/ClickLight/ClickProfileStore.swift index bbba2d5..afb8ea0 100644 --- a/Sources/ClickLight/ClickProfileStore.swift +++ b/Sources/ClickLight/ClickProfileStore.swift @@ -48,6 +48,7 @@ struct ClickProfileSettings: Codable, Equatable { var showMiddleClick: Bool var showDrag: Bool var showLaserPointer: Bool + var showArrowMode: Bool var showLiveKeyboardShortcuts: Bool var liveShortcutPosition: LiveShortcutPosition var liveShortcutSize: LiveShortcutSize @@ -85,6 +86,7 @@ struct ClickProfileSettings: Codable, Equatable { self.showMiddleClick = settings.showMiddleClick self.showDrag = settings.showDrag self.showLaserPointer = settings.showLaserPointer + self.showArrowMode = settings.showArrowMode self.showLiveKeyboardShortcuts = settings.showLiveKeyboardShortcuts self.liveShortcutPosition = settings.liveShortcutPosition self.liveShortcutSize = settings.liveShortcutSize @@ -123,6 +125,7 @@ struct ClickProfileSettings: Codable, Equatable { settings.showMiddleClick = showMiddleClick settings.showDrag = showDrag settings.showLaserPointer = showLaserPointer + settings.showArrowMode = showArrowMode settings.showLiveKeyboardShortcuts = showLiveKeyboardShortcuts settings.liveShortcutPosition = liveShortcutPosition settings.liveShortcutSize = liveShortcutSize @@ -153,6 +156,85 @@ struct ClickProfileSettings: Codable, Equatable { settings.laserInnerColorGreen = laserInnerColorGreen settings.laserInnerColorBlue = laserInnerColorBlue } + + private enum CodingKeys: String, CodingKey { + case showPress + case showRelease + case showRightClick + case showMiddleClick + case showDrag + case showLaserPointer + case showArrowMode + case showLiveKeyboardShortcuts + case liveShortcutPosition + case liveShortcutSize + case size + case intensity + case duration + case colorPreset + case customColorMode + case customColorRed + case customColorGreen + case customColorBlue + case customLeftColorRed + case customLeftColorGreen + case customLeftColorBlue + case customRightColorRed + case customRightColorGreen + case customRightColorBlue + case customMiddleColorRed + case customMiddleColorGreen + case customMiddleColorBlue + case customDragColorRed + case customDragColorGreen + case customDragColorBlue + case laserColorRed + case laserColorGreen + case laserColorBlue + case laserInnerColorRed + case laserInnerColorGreen + case laserInnerColorBlue + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + showPress = try container.decode(Bool.self, forKey: .showPress) + showRelease = try container.decode(Bool.self, forKey: .showRelease) + showRightClick = try container.decode(Bool.self, forKey: .showRightClick) + showMiddleClick = try container.decode(Bool.self, forKey: .showMiddleClick) + showDrag = try container.decode(Bool.self, forKey: .showDrag) + showLaserPointer = try container.decode(Bool.self, forKey: .showLaserPointer) + showArrowMode = try container.decodeIfPresent(Bool.self, forKey: .showArrowMode) ?? false + showLiveKeyboardShortcuts = try container.decode(Bool.self, forKey: .showLiveKeyboardShortcuts) + liveShortcutPosition = try container.decode(LiveShortcutPosition.self, forKey: .liveShortcutPosition) + liveShortcutSize = try container.decode(LiveShortcutSize.self, forKey: .liveShortcutSize) + size = try container.decode(CGFloat.self, forKey: .size) + intensity = try container.decode(CGFloat.self, forKey: .intensity) + duration = try container.decode(TimeInterval.self, forKey: .duration) + colorPreset = try container.decode(ClickColorPreset.self, forKey: .colorPreset) + customColorMode = try container.decode(CustomClickColorMode.self, forKey: .customColorMode) + customColorRed = try container.decode(CGFloat.self, forKey: .customColorRed) + customColorGreen = try container.decode(CGFloat.self, forKey: .customColorGreen) + customColorBlue = try container.decode(CGFloat.self, forKey: .customColorBlue) + customLeftColorRed = try container.decode(CGFloat.self, forKey: .customLeftColorRed) + customLeftColorGreen = try container.decode(CGFloat.self, forKey: .customLeftColorGreen) + customLeftColorBlue = try container.decode(CGFloat.self, forKey: .customLeftColorBlue) + customRightColorRed = try container.decode(CGFloat.self, forKey: .customRightColorRed) + customRightColorGreen = try container.decode(CGFloat.self, forKey: .customRightColorGreen) + customRightColorBlue = try container.decode(CGFloat.self, forKey: .customRightColorBlue) + customMiddleColorRed = try container.decode(CGFloat.self, forKey: .customMiddleColorRed) + customMiddleColorGreen = try container.decode(CGFloat.self, forKey: .customMiddleColorGreen) + customMiddleColorBlue = try container.decode(CGFloat.self, forKey: .customMiddleColorBlue) + customDragColorRed = try container.decode(CGFloat.self, forKey: .customDragColorRed) + customDragColorGreen = try container.decode(CGFloat.self, forKey: .customDragColorGreen) + customDragColorBlue = try container.decode(CGFloat.self, forKey: .customDragColorBlue) + laserColorRed = try container.decode(CGFloat.self, forKey: .laserColorRed) + laserColorGreen = try container.decode(CGFloat.self, forKey: .laserColorGreen) + laserColorBlue = try container.decode(CGFloat.self, forKey: .laserColorBlue) + laserInnerColorRed = try container.decode(CGFloat.self, forKey: .laserInnerColorRed) + laserInnerColorGreen = try container.decode(CGFloat.self, forKey: .laserInnerColorGreen) + laserInnerColorBlue = try container.decode(CGFloat.self, forKey: .laserInnerColorBlue) + } } @MainActor diff --git a/Sources/ClickLight/HotKeyBinding.swift b/Sources/ClickLight/HotKeyBinding.swift index 4634f56..f46141d 100644 --- a/Sources/ClickLight/HotKeyBinding.swift +++ b/Sources/ClickLight/HotKeyBinding.swift @@ -160,6 +160,8 @@ struct HotKeyBinding: Equatable, Hashable, Sendable { enum ClickShortcutAction: String, CaseIterable, Identifiable, Sendable { case toggleEnabled case toggleLaserPointer + case toggleArrowMode + case clearArrows case toggleShowPress case toggleShowRelease case toggleShowRightClick @@ -176,6 +178,10 @@ enum ClickShortcutAction: String, CaseIterable, Identifiable, Sendable { return "Toggle ClickLight" case .toggleLaserPointer: return "Toggle Laser Pointer" + case .toggleArrowMode: + return "Toggle Arrow Mode" + case .clearArrows: + return "Clear Arrows" case .toggleShowPress: return "Toggle Press" case .toggleShowRelease: @@ -199,6 +205,10 @@ enum ClickShortcutAction: String, CaseIterable, Identifiable, Sendable { return 1 case .toggleLaserPointer: return 2 + case .toggleArrowMode: + return 10 + case .clearArrows: + return 11 case .toggleShowPress: return 3 case .toggleShowRelease: @@ -220,7 +230,10 @@ enum ClickShortcutAction: String, CaseIterable, Identifiable, Sendable { switch self { case .toggleEnabled: return HotKeyBinding(keyCode: kVK_ANSI_L, carbonModifiers: HotKeyBinding.defaultToggleModifiers) + case .clearArrows: + return HotKeyBinding(keyCode: kVK_ANSI_C, carbonModifiers: HotKeyBinding.defaultToggleModifiers) case .toggleLaserPointer, + .toggleArrowMode, .toggleShowPress, .toggleShowRelease, .toggleShowRightClick, diff --git a/Sources/ClickLight/OverlayCoordinator.swift b/Sources/ClickLight/OverlayCoordinator.swift index ba489be..71e1494 100644 --- a/Sources/ClickLight/OverlayCoordinator.swift +++ b/Sources/ClickLight/OverlayCoordinator.swift @@ -28,9 +28,13 @@ final class OverlayCoordinator { overlaysByScreenID.values.forEach { $0.apply(settings: settings) } } + func clearArrows() { + overlaysByScreenID.values.forEach { $0.clearArrows() } + } + func show(_ event: ClickEvent) { guard settings.isEnabled else { return } - guard settings.showLaserPointer || shouldShow(event.kind) else { return } + guard settings.showLaserPointer || settings.showArrowMode || shouldShow(event.kind) else { return } guard shouldAccept(event) else { return } guard let screen = screen(containing: event.location) else { return } diff --git a/Sources/ClickLight/SettingsStore.swift b/Sources/ClickLight/SettingsStore.swift index 58dcdd0..e7f6dd9 100644 --- a/Sources/ClickLight/SettingsStore.swift +++ b/Sources/ClickLight/SettingsStore.swift @@ -8,6 +8,7 @@ struct ClickSettings: Equatable { var showMiddleClick: Bool var showDrag: Bool var showLaserPointer: Bool + var showArrowMode: Bool var showLiveKeyboardShortcuts: Bool var liveShortcutPosition: LiveShortcutPosition var liveShortcutSize: LiveShortcutSize @@ -46,6 +47,8 @@ struct ClickSettings: Equatable { var laserInnerColorBlue: CGFloat var toggleEnabledHotKey: HotKeyBinding? var toggleLaserPointerHotKey: HotKeyBinding? + var toggleArrowModeHotKey: HotKeyBinding? + var clearArrowsHotKey: HotKeyBinding? var toggleShowPressHotKey: HotKeyBinding? var toggleShowReleaseHotKey: HotKeyBinding? var toggleShowRightClickHotKey: HotKeyBinding? @@ -134,6 +137,7 @@ struct ClickSettings: Equatable { showMiddleClick: true, showDrag: true, showLaserPointer: false, + showArrowMode: false, showLiveKeyboardShortcuts: false, liveShortcutPosition: .bottomCenter, liveShortcutSize: .medium, @@ -172,6 +176,8 @@ struct ClickSettings: Equatable { laserInnerColorBlue: 1.0, toggleEnabledHotKey: ClickShortcutAction.toggleEnabled.defaultBinding, toggleLaserPointerHotKey: ClickShortcutAction.toggleLaserPointer.defaultBinding, + toggleArrowModeHotKey: ClickShortcutAction.toggleArrowMode.defaultBinding, + clearArrowsHotKey: ClickShortcutAction.clearArrows.defaultBinding, toggleShowPressHotKey: ClickShortcutAction.toggleShowPress.defaultBinding, toggleShowReleaseHotKey: ClickShortcutAction.toggleShowRelease.defaultBinding, toggleShowRightClickHotKey: ClickShortcutAction.toggleShowRightClick.defaultBinding, @@ -193,6 +199,10 @@ struct ClickSettings: Equatable { return toggleEnabledHotKey case .toggleLaserPointer: return toggleLaserPointerHotKey + case .toggleArrowMode: + return toggleArrowModeHotKey + case .clearArrows: + return clearArrowsHotKey case .toggleShowPress: return toggleShowPressHotKey case .toggleShowRelease: @@ -216,6 +226,10 @@ struct ClickSettings: Equatable { toggleEnabledHotKey = binding case .toggleLaserPointer: toggleLaserPointerHotKey = binding + case .toggleArrowMode: + toggleArrowModeHotKey = binding + case .clearArrows: + clearArrowsHotKey = binding case .toggleShowPress: toggleShowPressHotKey = binding case .toggleShowRelease: @@ -453,6 +467,7 @@ final class SettingsStore { static let showMiddleClick = "showMiddleClick" static let showDrag = "showDrag" static let showLaserPointer = "showLaserPointer" + static let showArrowMode = "showArrowMode" static let showLiveKeyboardShortcuts = "showLiveKeyboardShortcuts" static let liveShortcutPosition = "liveShortcutPosition" static let liveShortcutSize = "liveShortcutSize" @@ -495,6 +510,12 @@ final class SettingsStore { static let toggleLaserPointerHotKeyCode = "toggleLaserPointerHotKeyCode" static let toggleLaserPointerHotKeyModifiers = "toggleLaserPointerHotKeyModifiers" static let toggleLaserPointerHotKeyIsEnabled = "toggleLaserPointerHotKeyIsEnabled" + static let toggleArrowModeHotKeyCode = "toggleArrowModeHotKeyCode" + static let toggleArrowModeHotKeyModifiers = "toggleArrowModeHotKeyModifiers" + static let toggleArrowModeHotKeyIsEnabled = "toggleArrowModeHotKeyIsEnabled" + static let clearArrowsHotKeyCode = "clearArrowsHotKeyCode" + static let clearArrowsHotKeyModifiers = "clearArrowsHotKeyModifiers" + static let clearArrowsHotKeyIsEnabled = "clearArrowsHotKeyIsEnabled" static let toggleShowPressHotKeyCode = "toggleShowPressHotKeyCode" static let toggleShowPressHotKeyModifiers = "toggleShowPressHotKeyModifiers" static let toggleShowPressHotKeyIsEnabled = "toggleShowPressHotKeyIsEnabled" @@ -535,6 +556,7 @@ final class SettingsStore { showMiddleClick: defaults.bool(forKey: Key.showMiddleClick), showDrag: defaults.bool(forKey: Key.showDrag), showLaserPointer: defaults.bool(forKey: Key.showLaserPointer), + showArrowMode: defaults.bool(forKey: Key.showArrowMode), showLiveKeyboardShortcuts: defaults.bool(forKey: Key.showLiveKeyboardShortcuts), liveShortcutPosition: LiveShortcutPosition(rawValue: defaults.string(forKey: Key.liveShortcutPosition) ?? "") ?? .bottomCenter, liveShortcutSize: LiveShortcutSize(rawValue: defaults.string(forKey: Key.liveShortcutSize) ?? "") ?? .medium, @@ -581,6 +603,16 @@ final class SettingsStore { modifiers: Key.toggleLaserPointerHotKeyModifiers, isEnabled: Key.toggleLaserPointerHotKeyIsEnabled ), + toggleArrowModeHotKey: shortcutBinding( + keyCode: Key.toggleArrowModeHotKeyCode, + modifiers: Key.toggleArrowModeHotKeyModifiers, + isEnabled: Key.toggleArrowModeHotKeyIsEnabled + ), + clearArrowsHotKey: shortcutBinding( + keyCode: Key.clearArrowsHotKeyCode, + modifiers: Key.clearArrowsHotKeyModifiers, + isEnabled: Key.clearArrowsHotKeyIsEnabled + ), toggleShowPressHotKey: shortcutBinding( keyCode: Key.toggleShowPressHotKeyCode, modifiers: Key.toggleShowPressHotKeyModifiers, @@ -626,6 +658,7 @@ final class SettingsStore { defaults.set(newValue.showMiddleClick, forKey: Key.showMiddleClick) defaults.set(newValue.showDrag, forKey: Key.showDrag) defaults.set(newValue.showLaserPointer, forKey: Key.showLaserPointer) + defaults.set(newValue.showArrowMode, forKey: Key.showArrowMode) defaults.set(newValue.showLiveKeyboardShortcuts, forKey: Key.showLiveKeyboardShortcuts) defaults.set(newValue.liveShortcutPosition.rawValue, forKey: Key.liveShortcutPosition) defaults.set(newValue.liveShortcutSize.rawValue, forKey: Key.liveShortcutSize) @@ -664,6 +697,8 @@ final class SettingsStore { defaults.set(Double(newValue.laserInnerColorBlue), forKey: Key.laserInnerColorBlue) saveShortcutBinding(newValue.toggleEnabledHotKey, keyCode: Key.toggleEnabledHotKeyCode, modifiers: Key.toggleEnabledHotKeyModifiers, isEnabled: Key.toggleEnabledHotKeyIsEnabled) saveShortcutBinding(newValue.toggleLaserPointerHotKey, keyCode: Key.toggleLaserPointerHotKeyCode, modifiers: Key.toggleLaserPointerHotKeyModifiers, isEnabled: Key.toggleLaserPointerHotKeyIsEnabled) + saveShortcutBinding(newValue.toggleArrowModeHotKey, keyCode: Key.toggleArrowModeHotKeyCode, modifiers: Key.toggleArrowModeHotKeyModifiers, isEnabled: Key.toggleArrowModeHotKeyIsEnabled) + saveShortcutBinding(newValue.clearArrowsHotKey, keyCode: Key.clearArrowsHotKeyCode, modifiers: Key.clearArrowsHotKeyModifiers, isEnabled: Key.clearArrowsHotKeyIsEnabled) saveShortcutBinding(newValue.toggleShowPressHotKey, keyCode: Key.toggleShowPressHotKeyCode, modifiers: Key.toggleShowPressHotKeyModifiers, isEnabled: Key.toggleShowPressHotKeyIsEnabled) saveShortcutBinding(newValue.toggleShowReleaseHotKey, keyCode: Key.toggleShowReleaseHotKeyCode, modifiers: Key.toggleShowReleaseHotKeyModifiers, isEnabled: Key.toggleShowReleaseHotKeyIsEnabled) saveShortcutBinding(newValue.toggleShowRightClickHotKey, keyCode: Key.toggleShowRightClickHotKeyCode, modifiers: Key.toggleShowRightClickHotKeyModifiers, isEnabled: Key.toggleShowRightClickHotKeyIsEnabled) @@ -706,6 +741,7 @@ final class SettingsStore { Key.showMiddleClick: defaults.showMiddleClick, Key.showDrag: defaults.showDrag, Key.showLaserPointer: defaults.showLaserPointer, + Key.showArrowMode: defaults.showArrowMode, Key.showLiveKeyboardShortcuts: defaults.showLiveKeyboardShortcuts, Key.liveShortcutPosition: defaults.liveShortcutPosition.rawValue, Key.liveShortcutSize: defaults.liveShortcutSize.rawValue, @@ -748,6 +784,12 @@ final class SettingsStore { Key.toggleLaserPointerHotKeyCode: 0, Key.toggleLaserPointerHotKeyModifiers: 0, Key.toggleLaserPointerHotKeyIsEnabled: false, + Key.toggleArrowModeHotKeyCode: 0, + Key.toggleArrowModeHotKeyModifiers: 0, + Key.toggleArrowModeHotKeyIsEnabled: false, + Key.clearArrowsHotKeyCode: ClickShortcutAction.clearArrows.defaultBinding!.keyCode, + Key.clearArrowsHotKeyModifiers: ClickShortcutAction.clearArrows.defaultBinding!.carbonModifiers, + Key.clearArrowsHotKeyIsEnabled: true, Key.toggleShowPressHotKeyCode: 0, Key.toggleShowPressHotKeyModifiers: 0, Key.toggleShowPressHotKeyIsEnabled: false, diff --git a/Sources/ClickLight/StatusController.swift b/Sources/ClickLight/StatusController.swift index d2e1670..459123d 100644 --- a/Sources/ClickLight/StatusController.swift +++ b/Sources/ClickLight/StatusController.swift @@ -12,6 +12,7 @@ final class StatusController: NSObject { private let onCheckForUpdates: () -> Void private let updatesAreConfigured: () -> Bool private let onOpenSettings: (SettingsPane?) -> Void + private let onClearArrows: () -> Void private let onQuit: () -> Void private let onMenuWillOpen: () -> Void private let onMenuDidClose: () -> Void @@ -26,6 +27,7 @@ final class StatusController: NSObject { onCheckForUpdates: @escaping () -> Void, updatesAreConfigured: @escaping () -> Bool, onOpenSettings: @escaping (SettingsPane?) -> Void, + onClearArrows: @escaping () -> Void = {}, onQuit: @escaping () -> Void, onMenuWillOpen: @escaping () -> Void = {}, onMenuDidClose: @escaping () -> Void = {} @@ -38,6 +40,7 @@ final class StatusController: NSObject { self.onCheckForUpdates = onCheckForUpdates self.updatesAreConfigured = updatesAreConfigured self.onOpenSettings = onOpenSettings + self.onClearArrows = onClearArrows self.onQuit = onQuit self.onMenuWillOpen = onMenuWillOpen self.onMenuDidClose = onMenuDidClose @@ -115,6 +118,18 @@ final class StatusController: NSObject { action: #selector(toggleLaserPointer(_:)), shortcut: settings.shortcutBinding(for: .toggleLaserPointer) )) + menu.addItem(toggleItem( + title: "Arrow Mode", + isOn: settings.showArrowMode, + action: #selector(toggleArrowMode(_:)), + shortcut: settings.shortcutBinding(for: .toggleArrowMode) + )) + if settings.showArrowMode { + let clearArrowsItem = NSMenuItem(title: "Clear Arrows", action: #selector(clearArrows(_:)), keyEquivalent: "") + clearArrowsItem.target = self + applyShortcut(settings.shortcutBinding(for: .clearArrows), to: clearArrowsItem) + menu.addItem(clearArrowsItem) + } menu.addItem(toggleItem( title: "Show Live Keyboard Shortcuts", isOn: settings.showLiveKeyboardShortcuts, @@ -154,7 +169,7 @@ final class StatusController: NSObject { action: #selector(toggleDrag(_:)), shortcut: settings.shortcutBinding(for: .toggleShowDrag) ) - showDragItem.isEnabled = !settings.showLaserPointer + showDragItem.isEnabled = !settings.showLaserPointer && !settings.showArrowMode menu.addItem(showDragItem) menu.addItem(.separator()) } @@ -374,7 +389,12 @@ final class StatusController: NSObject { @objc private func toggleEnabled(_ sender: NSMenuItem) { dismissMenu(from: sender) - settingsStore.update { $0.isEnabled.toggle() } + settingsStore.update { + $0.isEnabled.toggle() + if !$0.isEnabled { + $0.showArrowMode = false + } + } } @objc private func openSettings() { @@ -416,7 +436,27 @@ final class StatusController: NSObject { @objc private func toggleLaserPointer(_ sender: NSMenuItem) { dismissMenu(from: sender) - settingsStore.update { $0.showLaserPointer.toggle() } + settingsStore.update { + $0.showLaserPointer.toggle() + if $0.showLaserPointer { + $0.showArrowMode = false + } + } + } + + @objc private func toggleArrowMode(_ sender: NSMenuItem) { + dismissMenu(from: sender) + settingsStore.update { + $0.showArrowMode.toggle() + if $0.showArrowMode { + $0.showLaserPointer = false + } + } + } + + @objc private func clearArrows(_ sender: NSMenuItem) { + dismissMenu(from: sender) + onClearArrows() } @objc private func toggleLiveKeyboardShortcuts(_ sender: NSMenuItem) {