diff --git a/Sources/ClickLight/ClickLightSettingsView.swift b/Sources/ClickLight/ClickLightSettingsView.swift index 0e81026..1f13104 100644 --- a/Sources/ClickLight/ClickLightSettingsView.swift +++ b/Sources/ClickLight/ClickLightSettingsView.swift @@ -377,6 +377,55 @@ struct ClickLightSettingsView: View { } } + SettingsCard(title: "Geometry", subtitle: "Choose the marker shape used for each click type.") { + HStack { + Spacer() + Button { + viewModel.resetGeometryToDefaults() + } label: { + Label("Reset Geometry", systemImage: "arrow.counterclockwise") + } + .controlSize(.small) + } + VStack(spacing: 10) { + geometryShapeRow( + title: "Left Press", + subtitle: "Shape for left-button press pulses.", + shape: geometryBinding(\.leftPressShape) + ) + geometryShapeRow( + title: "Left Release", + subtitle: "Shape for left-button release pulses.", + shape: geometryBinding(\.leftReleaseShape) + ) + geometryShapeRow( + title: "Right Press", + subtitle: "Shape for right-button press pulses.", + shape: geometryBinding(\.rightPressShape) + ) + geometryShapeRow( + title: "Right Release", + subtitle: "Shape for right-button release pulses.", + shape: geometryBinding(\.rightReleaseShape) + ) + geometryShapeRow( + title: "Middle Press", + subtitle: "Shape for middle-button press pulses.", + shape: geometryBinding(\.middlePressShape) + ) + geometryShapeRow( + title: "Middle Release", + subtitle: "Shape for middle-button release pulses.", + shape: geometryBinding(\.middleReleaseShape) + ) + geometryShapeRow( + title: "Drag Trail", + subtitle: "Shape used for drag pulses when the normal trail is active.", + shape: geometryBinding(\.dragShape) + ) + } + } + SettingsCard(title: "Color", subtitle: "Tint applied to every pulse.") { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 10) { @@ -812,6 +861,44 @@ struct ClickLightSettingsView: View { ) } + private func geometryBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { viewModel.settings[keyPath: keyPath] }, + set: { newValue in + viewModel.update { $0[keyPath: keyPath] = newValue } + } + ) + } + + private func geometryShapeRow(title: String, subtitle: String, shape: Binding) -> some View { + ModernRow(title: title, subtitle: subtitle) { + HStack(spacing: 8) { + Text(shape.wrappedValue.glyph) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 28, height: 28) + .background(Capsule().fill(Color.accentColor.opacity(0.14))) + .accessibilityHidden(true) + + Picker("Shape", selection: shape) { + ForEach(ClickGeometryShape.allCases, id: \ .self) { option in + Label { + Text(option.title) + } icon: { + Text(option.glyph) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + } + .tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityLabel(title) + } + } + } + private func saveCurrentProfile() { guard let profile = profileStore.saveProfile(named: profileName, from: viewModel.settings) else { return } profileName = "" diff --git a/Sources/ClickLight/ClickOverlayView.swift b/Sources/ClickLight/ClickOverlayView.swift index 43e4db1..8eea430 100644 --- a/Sources/ClickLight/ClickOverlayView.swift +++ b/Sources/ClickLight/ClickOverlayView.swift @@ -267,6 +267,8 @@ final class ClickOverlayView: NSView { context.setLineCap(.round) context.setLineJoin(.round) + let shape = settings.geometryShape(for: pulse.kind) + switch pulse.kind { case .leftDown: drawGlowIfNeeded( @@ -276,15 +278,15 @@ final class ClickOverlayView: NSView { color: pulse.color, alpha: fade * visualIntensity ) - drawRing( + drawShape( context: context, + shape: shape, point: pulse.point, radius: pulse.baseSize * (0.18 + 0.62 * eased), lineWidth: lineWidth, color: pulse.color, alpha: alpha ) - drawDot(context: context, point: pulse.point, radius: pulse.baseSize * 0.085, color: pulse.color, alpha: alpha * 0.75) case .leftUp: let releaseRadius = pulse.baseSize * (0.76 - 0.42 * eased) let releaseAlpha = alpha * 0.55 @@ -295,15 +297,15 @@ final class ClickOverlayView: NSView { color: pulse.color, alpha: fade * visualIntensity * 0.45 ) - drawRing( + drawShape( context: context, + shape: shape, point: pulse.point, radius: releaseRadius, lineWidth: lineWidth * 0.55, color: pulse.color, alpha: releaseAlpha ) - drawDot(context: context, point: pulse.point, radius: pulse.baseSize * 0.055, color: pulse.color, alpha: releaseAlpha * 0.6) case .rightDown: drawGlowIfNeeded( context: context, @@ -312,15 +314,15 @@ final class ClickOverlayView: NSView { color: pulse.color, alpha: fade * visualIntensity ) - drawRing( + drawShape( context: context, + shape: shape, point: pulse.point, radius: pulse.baseSize * (0.18 + 0.54 * eased), lineWidth: lineWidth, color: pulse.color, alpha: alpha ) - drawCrosshair(context: context, point: pulse.point, size: pulse.baseSize * 0.28, color: pulse.color, alpha: alpha * 0.85) case .rightUp: let releaseRadius = pulse.baseSize * (0.68 - 0.36 * eased) let releaseAlpha = alpha * 0.5 @@ -331,15 +333,15 @@ final class ClickOverlayView: NSView { color: pulse.color, alpha: fade * visualIntensity * 0.4 ) - drawRing( + drawShape( context: context, + shape: shape, point: pulse.point, radius: releaseRadius, lineWidth: lineWidth * 0.55, color: pulse.color, alpha: releaseAlpha ) - drawCrosshair(context: context, point: pulse.point, size: pulse.baseSize * (0.16 + 0.08 * fade), color: pulse.color, alpha: releaseAlpha * 0.7) case .middleDown: drawGlowIfNeeded( context: context, @@ -348,17 +350,17 @@ final class ClickOverlayView: NSView { color: pulse.color, alpha: fade * visualIntensity ) - drawRing( + drawShape( context: context, + shape: shape, point: pulse.point, radius: pulse.baseSize * (0.16 + 0.52 * eased), lineWidth: lineWidth, color: pulse.color, alpha: alpha ) - drawDiamond(context: context, point: pulse.point, size: pulse.baseSize * 0.24, color: pulse.color, alpha: alpha * 0.86) case .middleUp: - let releaseSize = pulse.baseSize * (0.32 - 0.12 * eased) + let releaseRadius = pulse.baseSize * (0.32 - 0.12 * eased) let releaseAlpha = alpha * 0.5 drawGlowIfNeeded( context: context, @@ -367,14 +369,24 @@ final class ClickOverlayView: NSView { color: pulse.color, alpha: fade * visualIntensity * 0.38 ) - drawDiamond(context: context, point: pulse.point, size: releaseSize, color: pulse.color, alpha: releaseAlpha * 0.82) + drawShape( + context: context, + shape: shape, + point: pulse.point, + radius: releaseRadius, + lineWidth: lineWidth * 0.55, + color: pulse.color, + alpha: releaseAlpha + ) case .drag: - drawDot( + drawShape( context: context, + shape: shape, point: pulse.point, - radius: pulse.baseSize * (0.08 + 0.065 * visualIntensity), + radius: pulse.baseSize * (0.18 + 0.08 * visualIntensity), + lineWidth: lineWidth * 0.8, color: pulse.color, - alpha: alpha * 0.78 + alpha: alpha * 0.86 ) case .move: break @@ -401,6 +413,35 @@ final class ClickOverlayView: NSView { )) } + private func drawShape( + context: CGContext, + shape: ClickGeometryShape, + point: CGPoint, + radius: CGFloat, + lineWidth: CGFloat, + color: NSColor, + alpha: CGFloat + ) { + switch shape { + case .dot: + drawDot(context: context, point: point, radius: max(2, radius * 0.42), color: color, alpha: alpha) + case .ring: + drawRing(context: context, point: point, radius: radius, lineWidth: max(2, lineWidth), color: color, alpha: alpha) + case .diamond: + drawDiamond(context: context, point: point, size: max(4, radius * 0.62), color: color, alpha: alpha) + case .cross: + drawCrosshair(context: context, point: point, size: max(4, radius * 0.72), color: color, alpha: alpha) + case .square: + drawSquare(context: context, point: point, size: max(4, radius * 0.72), color: color, alpha: alpha) + case .triangle: + drawTriangle(context: context, point: point, size: max(4, radius * 0.82), color: color, alpha: alpha) + case .hexagon: + drawHexagon(context: context, point: point, size: max(4, radius * 0.82), color: color, alpha: alpha) + case .capsule: + drawCapsule(context: context, point: point, size: max(4, radius * 0.9), color: color, alpha: alpha) + } + } + private func drawRing( context: CGContext, point: CGPoint, @@ -439,6 +480,47 @@ final class ClickOverlayView: NSView { context.strokePath() } + private func drawSquare(context: CGContext, point: CGPoint, size: CGFloat, color: NSColor, alpha: CGFloat) { + context.setFillColor(color.withAlphaComponent(alpha).cgColor) + let rect = CGRect(x: point.x - size, y: point.y - size, width: size * 2, height: size * 2) + context.addPath(CGPath(roundedRect: rect, cornerWidth: size * 0.18, cornerHeight: size * 0.18, transform: nil)) + context.fillPath() + } + + private func drawTriangle(context: CGContext, point: CGPoint, size: CGFloat, color: NSColor, alpha: CGFloat) { + context.setFillColor(color.withAlphaComponent(alpha).cgColor) + let path = CGMutablePath() + path.move(to: CGPoint(x: point.x, y: point.y + size)) + path.addLine(to: CGPoint(x: point.x + size * 0.95, y: point.y - size * 0.55)) + path.addLine(to: CGPoint(x: point.x - size * 0.95, y: point.y - size * 0.55)) + path.closeSubpath() + context.addPath(path) + context.fillPath() + } + + private func drawHexagon(context: CGContext, point: CGPoint, size: CGFloat, color: NSColor, alpha: CGFloat) { + context.setFillColor(color.withAlphaComponent(alpha).cgColor) + let path = CGMutablePath() + let cos60 = CGFloat(0.5) + let sin60 = CGFloat(0.8660254) + path.move(to: CGPoint(x: point.x + size, y: point.y)) + path.addLine(to: CGPoint(x: point.x + size * cos60, y: point.y + size * sin60)) + path.addLine(to: CGPoint(x: point.x - size * cos60, y: point.y + size * sin60)) + path.addLine(to: CGPoint(x: point.x - size, y: point.y)) + path.addLine(to: CGPoint(x: point.x - size * cos60, y: point.y - size * sin60)) + path.addLine(to: CGPoint(x: point.x + size * cos60, y: point.y - size * sin60)) + path.closeSubpath() + context.addPath(path) + context.fillPath() + } + + private func drawCapsule(context: CGContext, point: CGPoint, size: CGFloat, color: NSColor, alpha: CGFloat) { + context.setFillColor(color.withAlphaComponent(alpha).cgColor) + let rect = CGRect(x: point.x - size * 0.92, y: point.y - size * 0.42, width: size * 1.84, height: size * 0.84) + context.addPath(CGPath(roundedRect: rect, cornerWidth: size * 0.42, cornerHeight: size * 0.42, transform: nil)) + context.fillPath() + } + private func drawDiamond(context: CGContext, point: CGPoint, size: CGFloat, color: NSColor, alpha: CGFloat) { context.setFillColor(color.withAlphaComponent(alpha).cgColor) context.move(to: CGPoint(x: point.x, y: point.y + size)) diff --git a/Sources/ClickLight/ClickProfileStore.swift b/Sources/ClickLight/ClickProfileStore.swift index bbba2d5..7880d67 100644 --- a/Sources/ClickLight/ClickProfileStore.swift +++ b/Sources/ClickLight/ClickProfileStore.swift @@ -54,6 +54,13 @@ struct ClickProfileSettings: Codable, Equatable { var size: CGFloat var intensity: CGFloat var duration: TimeInterval + var leftPressShape: ClickGeometryShape + var leftReleaseShape: ClickGeometryShape + var rightPressShape: ClickGeometryShape + var rightReleaseShape: ClickGeometryShape + var middlePressShape: ClickGeometryShape + var middleReleaseShape: ClickGeometryShape + var dragShape: ClickGeometryShape var colorPreset: ClickColorPreset var customColorMode: CustomClickColorMode var customColorRed: CGFloat @@ -91,6 +98,13 @@ struct ClickProfileSettings: Codable, Equatable { self.size = settings.size self.intensity = settings.intensity self.duration = settings.duration + self.leftPressShape = settings.leftPressShape + self.leftReleaseShape = settings.leftReleaseShape + self.rightPressShape = settings.rightPressShape + self.rightReleaseShape = settings.rightReleaseShape + self.middlePressShape = settings.middlePressShape + self.middleReleaseShape = settings.middleReleaseShape + self.dragShape = settings.dragShape self.colorPreset = settings.colorPreset self.customColorMode = settings.customColorMode self.customColorRed = settings.customColorRed @@ -129,6 +143,13 @@ struct ClickProfileSettings: Codable, Equatable { settings.size = size settings.intensity = intensity settings.duration = duration + settings.leftPressShape = leftPressShape + settings.leftReleaseShape = leftReleaseShape + settings.rightPressShape = rightPressShape + settings.rightReleaseShape = rightReleaseShape + settings.middlePressShape = middlePressShape + settings.middleReleaseShape = middleReleaseShape + settings.dragShape = dragShape settings.colorPreset = colorPreset settings.customColorMode = customColorMode settings.customColorRed = customColorRed diff --git a/Sources/ClickLight/SettingsStore.swift b/Sources/ClickLight/SettingsStore.swift index 58dcdd0..6dfe3d3 100644 --- a/Sources/ClickLight/SettingsStore.swift +++ b/Sources/ClickLight/SettingsStore.swift @@ -21,6 +21,13 @@ struct ClickSettings: Equatable { var size: CGFloat var intensity: CGFloat var duration: TimeInterval + var leftPressShape: ClickGeometryShape + var leftReleaseShape: ClickGeometryShape + var rightPressShape: ClickGeometryShape + var rightReleaseShape: ClickGeometryShape + var middlePressShape: ClickGeometryShape + var middleReleaseShape: ClickGeometryShape + var dragShape: ClickGeometryShape var colorPreset: ClickColorPreset var customColorMode: CustomClickColorMode var customColorRed: CGFloat @@ -147,6 +154,13 @@ struct ClickSettings: Equatable { size: 64, intensity: 0.7, duration: 0.48, + leftPressShape: .ring, + leftReleaseShape: .ring, + rightPressShape: .cross, + rightReleaseShape: .cross, + middlePressShape: .diamond, + middleReleaseShape: .diamond, + dragShape: .dot, colorPreset: .default, customColorMode: .all, customColorRed: 0.0, @@ -181,6 +195,27 @@ struct ClickSettings: Equatable { toggleLiveKeyboardShortcutsHotKey: ClickShortcutAction.toggleLiveKeyboardShortcuts.defaultBinding ) + func geometryShape(for kind: ClickKind) -> ClickGeometryShape { + switch kind { + case .leftDown: + return leftPressShape + case .leftUp: + return leftReleaseShape + case .rightDown: + return rightPressShape + case .rightUp: + return rightReleaseShape + case .middleDown: + return middlePressShape + case .middleUp: + return middleReleaseShape + case .drag: + return dragShape + case .move: + return .dot + } + } + var shortcutBindings: [ClickShortcutAction: HotKeyBinding] { Dictionary(uniqueKeysWithValues: ClickShortcutAction.allCases.compactMap { action in shortcutBinding(for: action).map { (action, $0) } @@ -251,6 +286,16 @@ struct ClickSettings: Equatable { action.defaultBinding } + mutating func resetGeometryToDefaults() { + self.leftPressShape = Self.defaults.leftPressShape + self.leftReleaseShape = Self.defaults.leftReleaseShape + self.rightPressShape = Self.defaults.rightPressShape + self.rightReleaseShape = Self.defaults.rightReleaseShape + self.middlePressShape = Self.defaults.middlePressShape + self.middleReleaseShape = Self.defaults.middleReleaseShape + self.dragShape = Self.defaults.dragShape + } + mutating func applyRandomizedStyle() { let size = ClickSettingOptions.sizePresets.randomElement()?.value ?? Double(Self.defaults.size) let intensity = ClickSettingOptions.intensityPresets.randomElement()?.value ?? Double(Self.defaults.intensity) @@ -289,6 +334,59 @@ struct ClickSettings: Equatable { } } +enum ClickGeometryShape: String, CaseIterable, Codable, Equatable { + case dot + case ring + case diamond + case cross + case square + case triangle + case hexagon + case capsule + + var title: String { + switch self { + case .dot: + return "Dot" + case .ring: + return "Ring" + case .diamond: + return "Diamond" + case .cross: + return "Cross" + case .square: + return "Square" + case .triangle: + return "Triangle" + case .hexagon: + return "Hexagon" + case .capsule: + return "Capsule" + } + } + + var glyph: String { + switch self { + case .dot: + return "●" + case .ring: + return "◌" + case .diamond: + return "◇" + case .cross: + return "✚" + case .square: + return "■" + case .triangle: + return "▲" + case .hexagon: + return "⬢" + case .capsule: + return "◍" + } + } +} + enum CustomClickColorMode: String, CaseIterable, Codable, Equatable { case all case byClick @@ -461,6 +559,13 @@ final class SettingsStore { static let size = "size" static let intensity = "intensity" static let duration = "duration" + static let leftPressShape = "leftPressShape" + static let leftReleaseShape = "leftReleaseShape" + static let rightPressShape = "rightPressShape" + static let rightReleaseShape = "rightReleaseShape" + static let middlePressShape = "middlePressShape" + static let middleReleaseShape = "middleReleaseShape" + static let dragShape = "dragShape" static let colorPreset = "colorPreset" static let customColorMode = "customColorMode" static let customColorRed = "customColorRed" @@ -548,6 +653,13 @@ final class SettingsStore { size: CGFloat(defaults.double(forKey: Key.size)), intensity: CGFloat(defaults.double(forKey: Key.intensity)), duration: defaults.double(forKey: Key.duration), + leftPressShape: ClickGeometryShape(rawValue: defaults.string(forKey: Key.leftPressShape) ?? "") ?? .ring, + leftReleaseShape: ClickGeometryShape(rawValue: defaults.string(forKey: Key.leftReleaseShape) ?? "") ?? .ring, + rightPressShape: ClickGeometryShape(rawValue: defaults.string(forKey: Key.rightPressShape) ?? "") ?? .cross, + rightReleaseShape: ClickGeometryShape(rawValue: defaults.string(forKey: Key.rightReleaseShape) ?? "") ?? .cross, + middlePressShape: ClickGeometryShape(rawValue: defaults.string(forKey: Key.middlePressShape) ?? "") ?? .diamond, + middleReleaseShape: ClickGeometryShape(rawValue: defaults.string(forKey: Key.middleReleaseShape) ?? "") ?? .diamond, + dragShape: ClickGeometryShape(rawValue: defaults.string(forKey: Key.dragShape) ?? "") ?? .dot, colorPreset: ClickColorPreset(rawValue: defaults.string(forKey: Key.colorPreset) ?? "") ?? .default, customColorMode: CustomClickColorMode(rawValue: defaults.string(forKey: Key.customColorMode) ?? "") ?? .all, customColorRed: CGFloat(defaults.double(forKey: Key.customColorRed)).sanitizedColorComponent, @@ -639,6 +751,13 @@ final class SettingsStore { defaults.set(Double(newValue.size), forKey: Key.size) defaults.set(Double(newValue.intensity), forKey: Key.intensity) defaults.set(newValue.duration, forKey: Key.duration) + defaults.set(newValue.leftPressShape.rawValue, forKey: Key.leftPressShape) + defaults.set(newValue.leftReleaseShape.rawValue, forKey: Key.leftReleaseShape) + defaults.set(newValue.rightPressShape.rawValue, forKey: Key.rightPressShape) + defaults.set(newValue.rightReleaseShape.rawValue, forKey: Key.rightReleaseShape) + defaults.set(newValue.middlePressShape.rawValue, forKey: Key.middlePressShape) + defaults.set(newValue.middleReleaseShape.rawValue, forKey: Key.middleReleaseShape) + defaults.set(newValue.dragShape.rawValue, forKey: Key.dragShape) defaults.set(newValue.colorPreset.rawValue, forKey: Key.colorPreset) defaults.set(newValue.customColorMode.rawValue, forKey: Key.customColorMode) defaults.set(Double(newValue.customColorRed), forKey: Key.customColorRed) @@ -719,6 +838,13 @@ final class SettingsStore { Key.size: Double(defaults.size), Key.intensity: Double(defaults.intensity), Key.duration: defaults.duration, + Key.leftPressShape: defaults.leftPressShape.rawValue, + Key.leftReleaseShape: defaults.leftReleaseShape.rawValue, + Key.rightPressShape: defaults.rightPressShape.rawValue, + Key.rightReleaseShape: defaults.rightReleaseShape.rawValue, + Key.middlePressShape: defaults.middlePressShape.rawValue, + Key.middleReleaseShape: defaults.middleReleaseShape.rawValue, + Key.dragShape: defaults.dragShape.rawValue, Key.colorPreset: defaults.colorPreset.rawValue, Key.customColorMode: defaults.customColorMode.rawValue, Key.customColorRed: Double(defaults.customColorRed), diff --git a/Sources/ClickLight/SettingsWindowController.swift b/Sources/ClickLight/SettingsWindowController.swift index d0d0f2a..773bf1d 100644 --- a/Sources/ClickLight/SettingsWindowController.swift +++ b/Sources/ClickLight/SettingsWindowController.swift @@ -216,6 +216,10 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { update { $0.applyRandomizedStyle() } } + func resetGeometryToDefaults() { + update { $0.resetGeometryToDefaults() } + } + func applyProfile(_ profile: ClickSettingsProfile) { update { settings in profile.settings.apply(to: &settings)