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
24 changes: 22 additions & 2 deletions Sources/ClickLight/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 32 additions & 4 deletions Sources/ClickLight/ClickLightSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
141 changes: 140 additions & 1 deletion Sources/ClickLight/ClickOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
Comment thread
aurorascharff marked this conversation as resolved.

guard shouldShowPulse(for: event.kind) else { return }

pulses.append(ClickPulse(
Expand Down Expand Up @@ -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 }
Expand All @@ -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)
}
Comment thread
aurorascharff marked this conversation as resolved.
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
}
4 changes: 4 additions & 0 deletions Sources/ClickLight/ClickOverlayWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,8 @@ final class ClickOverlayWindow: NSWindow {
orderFrontRegardless()
overlayView.show(shortcut: shortcut, settings: settings)
}

func clearArrows() {
overlayView.clearArrows()
}
}
Loading
Loading