diff --git a/README.md b/README.md index 7c95504..7cbf446 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,24 @@ configuration.zoomValues = [.xxl, .s, .l, .xs] SCColorSampler.sample(configuration: configuration) { ... } ``` +#### Zoom with Mouse Wheel Inverted +```swift +// For example: +configuration.zoomWheelInverse = true +``` + +#### Change Loupe Location to avoid sight blocking +```swift +// For example: +configuration.loupeShape = .rect +configuration.loupeFollowDistance = 10 +configuration.loupeSize = .recOnly(256, 64) + +//configuration.loupeShape = .circle +//configuration.loupeFollowDistance = 1 +//configuration.loupeSize = .large +``` + #### Show Color Description

Image showing showColorDescription off option diff --git a/Sources/ColorSampler.swift b/Sources/ColorSampler.swift index 9551685..647f28a 100644 --- a/Sources/ColorSampler.swift +++ b/Sources/ColorSampler.swift @@ -10,6 +10,7 @@ import Combine import Foundation import ScreenCaptureKit import struct SwiftUI.Binding +import Carbon.HIToolbox internal class ColorSampler: NSObject { // Properties @@ -20,6 +21,8 @@ internal class ColorSampler: NSObject { var onMouseMovedHandlerBlock: ((NSColor) -> Void)? var selectionHandlerBlock: ((NSColor?) -> Void)? + var monitors: [Any?] = [] + var isRunning: Bool = false; // Functions func sample( @@ -40,6 +43,11 @@ internal class ColorSampler: NSObject { return } + // Make window a little bigger than use specified + let loupeSize = configuration.loupeSize.getSize() + let samplerWindowWidth = loupeSize.width + configuration.padding * 2 + let samplerWindowHeight = loupeSize.height + configuration.padding * 2 + var windowInit: ( contentRect: NSRect, styleMask: NSWindow.StyleMask, @@ -47,7 +55,7 @@ internal class ColorSampler: NSObject { defer: Bool ) { return ( - NSRect.init(origin: .zero, size: configuration.loupeSize.getSize()), + NSRect.init(origin: .zero, size: CGSize(width: samplerWindowWidth, height: samplerWindowHeight)), NSWindow.StyleMask.borderless, NSWindow.BackingStoreType.buffered, true @@ -75,15 +83,18 @@ internal class ColorSampler: NSObject { name: NSWindow.didResignKeyNotification, object: self.colorSamplerWindow ) - - NSApplication.shared.activate(ignoringOtherApps: true) - self.colorSamplerWindow?.makeKeyAndOrderFront(self) + addMouseMonitor() + // 这里有问题,激活放大镜后,其它程序都变灰色了,取色就不对了(已修复) + // NSApplication.shared.activate(ignoringOtherApps: false) + self.colorSamplerWindow?.orderFront(self) // 不能变成 key self.colorSamplerWindow?.orderedIndex = 0 - // prepare image for window's contentView in advance self.colorSamplerWindow?.mouseMoved(with: NSEvent()) - NSCursor.hide() + self.isRunning = true + if self.configuration?.loupeFollowMode == .center { + NSCursor.hide() + } } func reset() { @@ -99,4 +110,94 @@ internal class ColorSampler: NSObject { self.onMouseMovedHandlerBlock = nil self.selectionHandlerBlock = nil } + + func colorSelected() { + self.isRunning = false + self.colorSamplerWindow?.finalizeColor() + self.removeMonitors() + self.reset() + } + + func cancel() { + self.isRunning = false + self.colorSamplerWindow?.cancel() + self.removeMonitors() + self.reset() + } + + func addMouseMonitor() { + + // 假如鼠标移动过快导致窗口跟不上,需要此函数来找回监听。和键盘相关的全局事件需要辅助功能权限 + // 目前已经将隐形窗口放大(SCColorSamplerConfiguration.padding),这种情况应该很少出现了,如果出现,就需要这里发挥作用 + let global_mouseMoved = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved) { e in + self.colorSamplerWindow?.mouseMoved(with: e) + } + monitors.append(global_mouseMoved) + + let local_mouseExited = NSEvent.addLocalMonitorForEvents(matching: .mouseExited) { e in + self.colorSamplerWindow?.mouseMoved(with: e) + return e + } + monitors.append(local_mouseExited) + + // 该事件只监听除了自身以外的程序,用于在鼠标按下捕获颜色,和键盘相关的全局事件需要辅助功能权限 + let global_mouse_down = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { e in + guard self.isRunning else { + return + } + self.colorSelected() + } + monitors.append(global_mouse_down) + + // 用于在按下ESC关闭取色窗口,回车取色,和键盘相关的全局事件需要辅助功能权限 + let global_key = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { e in + guard self.isRunning else { + return + } + if e.keyCode == kVK_Escape { + self.cancel() + } + if e.keyCode == kVK_Return { + self.colorSelected() + } + } + monitors.append(global_key) + // 鼠标左键取色 + let local_mouse = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { e in + guard self.isRunning else { + return e + } + self.colorSelected() + return e + } + monitors.append(local_mouse) + + // 用于在按下ESC关闭取色窗口,回车取色 + let local_key = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in + guard self.isRunning else { + return e + } + if e.keyCode == kVK_Escape { + self.cancel() + } + if e.keyCode == kVK_Return { + self.colorSelected() + } + return e + } + monitors.append(local_key) + } + + func removeMonitors() { + for i in 0 ..< self.monitors.count { + if let m = self.monitors[i] { + do { + NSEvent.removeMonitor(m) + } catch { + } + } + } + // 防止出现 Thread 1: EXC_BAD_ACCESS (code=EXC_I386_GPFLT) + self.monitors = [] + } } diff --git a/Sources/ColorSamplerView.swift b/Sources/ColorSamplerView.swift index b9da2b6..353803d 100644 --- a/Sources/ColorSamplerView.swift +++ b/Sources/ColorSamplerView.swift @@ -14,27 +14,34 @@ internal class ColorSamplerView: NSView { var image: Binding! var loupeColor: Binding! - var quality: SCColorSamplerConfiguration.Quality! - var shape: SCColorSamplerConfiguration.LoupeShape! + var config: SCColorSamplerConfiguration! + var frameRect: NSRect! init( frame frameRect: NSRect, zoom: Binding, image: Binding, loupeColor: Binding, - shape: SCColorSamplerConfiguration.LoupeShape, - quality: SCColorSamplerConfiguration.Quality + config: SCColorSamplerConfiguration ) { self.zoom = zoom self.image = image - self.quality = quality self.loupeColor = loupeColor - self.shape = shape + self.config = config + self.frameRect = frameRect super.init( frame: frameRect ) } + private func getUserViewFrame() -> NSRect { + let windowFrame = self.window!.frame + var size: CGSize = config.loupeSize.getSize() + let originOffset = config.loupeFollowMode == .noBlock ? config.padding : 0 + var origin: CGPoint = .init(x: self.frameRect.origin.x + config.padding, y: self.frameRect.origin.y + originOffset) + return .init(origin: origin, size: size) + } + required init?(coder: NSCoder) { super.init(coder: coder) } @@ -48,14 +55,40 @@ internal class ColorSamplerView: NSView { // Weird ?? fatalError() } - - // Clear the drawing rect. - context.clear(self.bounds) - - let rect = self.bounds - - let width: CGFloat = rect.width - let height: CGFloat = rect.height + let quality = config.quality + let shape = config.loupeShape + let windowRect: NSRect = window!.frame + + // User specified region + // 这个 rect 是放大镜的绘画区域,它的坐标系是相对于这个view本身,因此它的原点不是零点,而是(P, P), P=config.padding + let rect: NSRect = .init(origin: .init(x: config.padding, y: config.padding), size: config.loupeSize.getSize()) + + // 以下debug信息非常重要,保留 +// print("window frame \(self.window!.frame.debugDescription)") +// print("view frame \(self.frame.debugDescription)") +// print("draw zone: \(rect.debugDescription)") +// +// // Invisible window for debug +// context.setLineWidth(4.0) +// context.setStrokeColor(CGColor(red: 255, green: 0, blue: 0, alpha: 1)) +// var shape1: SCColorSamplerConfiguration.LoupeShape = .rect +// context.addPath(shape1.path(in: rect)) +// context.strokePath() +// +// // Inviisible bounds window for debug +// context.setLineWidth(4.0) +// context.setStrokeColor(CGColor(red: 0, green: 255, blue: 0, alpha: 1)) +// var shape2: SCColorSamplerConfiguration.LoupeShape = .rect +// context.addPath(shape2.path(in: self.bounds)) +// context.strokePath() +// +// // Inviisible Out window for debug +// context.setLineWidth(4.0) +// context.setStrokeColor(CGColor(red: 0, green: 0, blue: 255, alpha: 1)) +// var shape3: SCColorSamplerConfiguration.LoupeShape = .rect +// context.addPath(shape3.path(in: windowRect)) +// context.strokePath() + // 以上debug信息非常重要,保留 // mask let path = shape.path(in: rect) @@ -68,6 +101,9 @@ internal class ColorSamplerView: NSView { } // draw image + let width: CGFloat = rect.width + let height: CGFloat = rect.height + context.setRenderingIntent(.relativeColorimetric) context.interpolationQuality = .none context.draw(image, in: rect) @@ -75,8 +111,9 @@ internal class ColorSamplerView: NSView { // Get dimensions let apertureSize: CGFloat = zoom.getApertureSize() - let x: CGFloat = (width / 2.0) - (apertureSize / 2.0) - let y: CGFloat = (height / 2.0) - (apertureSize / 2.0) + // 孔径位置 + let x: CGFloat = (self.frameRect.width / 2.0) - (apertureSize / 2.0) + let y: CGFloat = (self.frameRect.height / 2.0) - (apertureSize / 2.0) // Square pattern let replicatorLayer = CAReplicatorLayer() @@ -85,15 +122,15 @@ internal class ColorSamplerView: NSView { let squareSize = zoom.getSquarePatternSize() let squareDisplacement = zoom.getSquarePatternDisplacement() square.borderWidth = 0.5 - square.borderColor = .black.copy(alpha: 0.15) + square.borderColor = .black.copy(alpha: 0.05) square.frame = CGRect(x: x - (squareSize * 25), y: y - (squareSize * 25), width: squareSize, height: squareSize) - let instanceCount = 50 - - replicatorLayer.instanceCount = instanceCount + let instanceCount: Double = 50 + + replicatorLayer.instanceCount = Int(instanceCount) replicatorLayer.instanceTransform = CATransform3DMakeTranslation(squareSize, squareDisplacement, 0) replicatorLayer.addSublayer(square) @@ -102,7 +139,7 @@ internal class ColorSamplerView: NSView { outerReplicatorLayer.addSublayer(replicatorLayer) - outerReplicatorLayer.instanceCount = instanceCount + outerReplicatorLayer.instanceCount = Int(instanceCount) outerReplicatorLayer.instanceTransform = CATransform3DMakeTranslation(squareDisplacement, squareSize, 0) outerReplicatorLayer.render(in: context) @@ -111,6 +148,7 @@ internal class ColorSamplerView: NSView { let apertureRect = CGRect(x: x, y: y, width: apertureSize, height: apertureSize) context.setLineWidth(zoom.getApertureLineWidth()) context.setStrokeColor(loupeColor.wrappedValue.cgColor) +// context.setStrokeColor(CGColor(red: 255, green: 0, blue: 0, alpha: 1)) context.setShouldAntialias(false) context.stroke(apertureRect.insetBy(dx: zoom.getInsetAmount(), dy: zoom.getInsetAmount())) @@ -118,6 +156,7 @@ internal class ColorSamplerView: NSView { context.setShouldAntialias(true) context.setLineWidth(4.0) context.setStrokeColor(loupeColor.wrappedValue.cgColor) + //context.setStrokeColor(CGColor(red: 0, green: 255, blue: 0, alpha: 1)) context.addPath(path) context.strokePath() } diff --git a/Sources/ColorSamplerWindow.swift b/Sources/ColorSamplerWindow.swift index 6a13f2d..4163fbe 100644 --- a/Sources/ColorSamplerWindow.swift +++ b/Sources/ColorSamplerWindow.swift @@ -62,10 +62,11 @@ internal class ColorSamplerWindow: NSWindow { // NSWindow properties self.delegate = delegate self.isOpaque = false - self.backgroundColor = .clear + self.backgroundColor = .init(red: 1, green: 1, blue: 1, alpha: 0.001) // 让隐形窗口不可见,但是不能透传点击事件到底部 self.level = .screenSaver self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] self.ignoresMouseEvents = false + self.acceptsMouseMovedEvents = true // Start stream Task { await startStream() @@ -118,7 +119,7 @@ internal class ColorSamplerWindow: NSWindow { contentRect: .init( origin: .init( x: self.frame.midX - 50, - y: self.frame.minY - 35 + y: self.frame.minY - 35 + delegate.config.padding // 实时颜色的位置根据用户可见区域计算 ), size: .init( width: 100, @@ -133,10 +134,66 @@ internal class ColorSamplerWindow: NSWindow { } } + /// This function get user view frame from calculated window frame. + /// + /// Be cautious this function should only be used after window frame is set by `getWindowOriginPoint` + private func getUserViewSize() -> CGSize { + return unwrappedDelegate.config.loupeSize.getSize() + } + + // Get origin point(zero point) of the rectangle area(loupe) + private func getWindowOriginPoint(_ position: NSPoint, _ display: NSScreen) -> NSPoint { + let displayOrigin = display.frame.origin + // should minus display origin point for multiple displays + let position = NSPoint(x: position.x - displayOrigin.x, y: position.y - displayOrigin.y) + let config = unwrappedDelegate.config + let safeAreaDistance: CGFloat = 10 + + var origin: NSPoint = .zero + // 在隐形窗口之内的用户可见区域 + let size: CGSize = getUserViewSize() + // Need dodge when mouse reach edge of screen, especially bottom and right edge + switch config.loupeFollowMode { + case .center: + origin = .init(x: position.x - self.frame.size.width / 2, y: position.y - (self.frame.size.height / 2)) + case .noBlock: + if position.x + size.width >= display.frame.width - safeAreaDistance && position.y - size.height <= safeAreaDistance { + // right and bottom + origin = .init( + x: position.x - self.frame.size.width + config.padding, + y: position.y - config.padding + ) + } else if position.x + size.width >= display.frame.width - safeAreaDistance { // 使用用户可见区域判断 + // right + origin = .init( + x: position.x - self.frame.size.width + config.padding, + y: position.y - self.frame.size.height + config.padding - config.loupeFollowDistance // 但是使用窗口大小计算,因为计算的不是可见区域的原点,而是外部窗口的原点 + ) + } else if position.y - size.height <= safeAreaDistance { + // bottom + origin = .init( + x: position.x - config.padding, + y: position.y - config.padding + ) + } else { + // top and left + origin = .init( + x: position.x - config.padding + config.loupeFollowDistance, + y: position.y - self.frame.size.height + config.padding - config.loupeFollowDistance + ) + } + } + + // should add origin back cause we want an absolute value caculated base on (0,0) + return .init( + x: origin.x + displayOrigin.x, + y: origin.y + displayOrigin.y + ) + } // Override NSWindow methods + // 这个方法需要采样窗口一直是key,但是这样其它窗口就会失去焦点,颜色会变,因此不能再用了 override open func mouseMoved(with event: NSEvent) { let position = NSEvent.mouseLocation - guard let screenWithMouse = NSScreen.screens.first( where: { NSMouseInRect(position, $0.frame, false) } ) @@ -147,11 +204,8 @@ internal class ColorSamplerWindow: NSWindow { if self.activeDisplay != screenWithMouse { self.activeDisplay = screenWithMouse } - - let origin: NSPoint = .init( - x: position.x - (self.frame.size.width / 2), - y: position.y - (self.frame.size.height / 2) - ) + + let origin: NSPoint = getWindowOriginPoint(position, screenWithMouse) self.setFrameOrigin(origin) if let image = croppedImageBinding.wrappedValue, @@ -174,7 +228,16 @@ internal class ColorSamplerWindow: NSWindow { super.mouseMoved(with: event) } - override open func mouseDown(with event: NSEvent) { +// override open func mouseDown(with event: NSEvent) { +// if let color = self.croppedImageBinding.wrappedValue?.colorAtCenter(), +// let delegate = self.delegate as? ColorSamplerDelegate { +// delegate.callSelectionHandler(color: color) +// } +// self.orderOut(self) +// } +// + func finalizeColor() { +// print("finalize color down") if let color = self.croppedImageBinding.wrappedValue?.colorAtCenter(), let delegate = self.delegate as? ColorSamplerDelegate { delegate.callSelectionHandler(color: color) @@ -212,12 +275,14 @@ internal class ColorSamplerWindow: NSWindow { return } - if event.scrollingDeltaY < -1 { + let deltaY = delegate.config.zoomWheelInverse ? -event.scrollingDeltaY : event.scrollingDeltaY + + if deltaY < -1 { guard let nextZoom = zoom?.getNextZoom(available: delegate.config.zoomValues) else { return } zoom = nextZoom - } else if event.scrollingDeltaY > 1 { + } else if deltaY > 1 { guard let previousZoom = zoom?.getPreviousZoom(available: delegate.config.zoomValues) else { return } @@ -228,14 +293,22 @@ internal class ColorSamplerWindow: NSWindow { super.scrollWheel(with: event) } - override func keyDown(with event: NSEvent) { - if event.keyCode == kVK_Escape { - if let delegate = self.delegate as? ColorSamplerDelegate { - delegate.callSelectionHandler(color: nil) - } - self.orderOut(self) + func cancel() { + if let delegate = self.delegate as? ColorSamplerDelegate { + delegate.callSelectionHandler(color: nil) } + self.orderOut(self) } + + // 取消置顶后,这里的keydonw就不能用了 +// override func keyDown(with event: NSEvent) { +// if event.keyCode == kVK_Escape { +// if let delegate = self.delegate as? ColorSamplerDelegate { +// delegate.callSelectionHandler(color: nil) +// } +// self.orderOut(self) +// } +// } } extension ColorSamplerWindow { @@ -269,8 +342,7 @@ extension ColorSamplerWindow { zoom: zoomBinding, image: croppedImageBinding, loupeColor: loupeColorBinding, - shape: delegate.config.loupeShape, - quality: delegate.config.quality + config: delegate.config ) self.contentView = contentView if unwrappedDelegate.config.showColorDescription { @@ -282,7 +354,7 @@ extension ColorSamplerWindow { NSRect.init( origin: .init( x: self.frame.midX - newWidth / 2, - y: self.frame.minY - 35 + y: self.frame.minY - 35 + delegate.config.padding ), size: .init( width: newWidth, @@ -386,20 +458,23 @@ internal extension ColorSamplerWindow { var captureSize: CGFloat = round( round( - self.frame.size.width / self.zoom!.getPixelZoom(quality: delegate.config.quality) + delegate.config.loupeSize.getSize().width / self.zoom!.getPixelZoom(quality: delegate.config.quality) ) * delegate.config.quality.getMultiplier() ) if captureSize.truncatingRemainder(dividingBy: 2) != 0 { captureSize += 1 } + let loupeSize = delegate.config.loupeSize.getSize() + let captureSizeY = captureSize * loupeSize.height / loupeSize.width + let x = (position.x - display.frame.origin.x) * delegate.config.quality.getMultiplier() let y = (display.frame.height - (position.y - display.frame.origin.y)) * delegate.config.quality.getMultiplier() let captureRect = NSRect( x: x - (captureSize / 2), - y: y - (captureSize / 2), + y: y - (captureSizeY / 2), width: captureSize, - height: captureSize + height: captureSizeY ) guard let croppedImage = image.cropping(to: captureRect) else { diff --git a/Sources/SCColorSamplerConfiguration.swift b/Sources/SCColorSamplerConfiguration.swift index b663d9f..1381496 100644 --- a/Sources/SCColorSamplerConfiguration.swift +++ b/Sources/SCColorSamplerConfiguration.swift @@ -21,6 +21,7 @@ open class SCColorSamplerConfiguration: NSObject { private var _defaultZoom: ZoomValue = .m private var _loupeShape: LoupeShape = .roundedRect private var _showColorDescription: Bool = true + private var _zoomWheelInverse: Bool = false private var _colorDescriptionMethod: (NSColor) -> String = { color in let red = Int((color.redComponent * 255).rounded()) let green = Int((color.greenComponent * 255).rounded()) @@ -33,6 +34,8 @@ open class SCColorSamplerConfiguration: NSObject { return String(format: "%02x%02x%02x%02x", red, green, blue, alpha).uppercased() } } + private var _loupeFollowMode: LoupeFollowMode = .center + private var _loupeFollowDistance: Double = 10 // MARK: - Loupe shape public enum LoupeShape { @@ -56,6 +59,26 @@ open class SCColorSamplerConfiguration: NSObject { } } + // MARK: - Loupe follow + public enum LoupeFollowMode { + case center + case noBlock + } + + /// SCColorSamplerConfiguration property that specifies the distance from the loupe to the mouse. + /// + open var loupeFollowMode: LoupeFollowMode { get { _loupeFollowMode } set { _loupeFollowMode = newValue } } + + /// SCColorSamplerConfiguration property that specifies the color sampler loupe shape. + /// + /// It should be set to the approximate mouse size. + open var loupeFollowDistance: Double { get { _loupeFollowDistance } set { _loupeFollowDistance = newValue } } + + /// SCColorSamplerConfiguration property that specifies the invisible padding, used to initialize an invisible window to listen on mouse event + /// + /// It should be a little greater than `loupeFollowDistance` + var padding: Double { get { _loupeFollowDistance + 300 } } + /// SCColorSamplerConfiguration property that specifies the color sampler loupe shape. /// /// - Possible values are: @@ -70,6 +93,7 @@ open class SCColorSamplerConfiguration: NSObject { case medium case large case custom(CGFloat) + case recOnly(CGFloat, CGFloat) internal func getSize() -> CGSize { switch self { @@ -81,6 +105,8 @@ open class SCColorSamplerConfiguration: NSObject { return .init(width: 160, height: 160) case .custom(let value): return .init(width: value, height: value) + case .recOnly(let width, let height): + return .init(width: width, height: height) } } } @@ -239,6 +265,17 @@ open class SCColorSamplerConfiguration: NSObject { } } + // MARK: - ZOOM + /// SCColorSamplerConfiguration property that specifies if the mouse wheel should be inverted when zooming. It has nothing to do with `event.isDirectionInvertedFromDevice`. It just provides a way to invert the mouse wheel without forcing users to change their system config. + /// + /// - Possible values are: + /// * false (default) + /// * true + open var zoomWheelInverse: Bool { + get { _zoomWheelInverse } + set { _zoomWheelInverse = newValue } + } + /// SCColorSamplerConfiguration property that specifies the possible zoom values. Set to empty array to disable zoom functionality. Set the `defaultZoomValue` property to set the starting zoom value. /// /// Define all the possible zoom values in an array like so: [.s, .m, .l, .xl]