| 1 | import AppKit |
| 2 | |
| 3 | /// The shared 8-color quick palette (toolbar picker, radial picker). |
| 4 | enum Palette { |
| 5 | static let colors: [NSColor] = [.black, .white, .systemRed, .systemOrange, |
| 6 | .systemYellow, .systemGreen, .systemBlue, |
| 7 | .systemPurple] |
| 8 | } |
| 9 | |
| 10 | /// Krita-style picker: a hue ring around a saturation/value triangle, with |
| 11 | /// the quick palette along the bottom. It pops out of the toolbar's color |
| 12 | /// swatch on HOVER so changing color is one smooth gesture, and closes when |
| 13 | /// the mouse wanders off. |
| 14 | final class ColorPickerPanel: NSPanel { |
| 15 | private static var current: ColorPickerPanel? |
| 16 | private var anchorView: NSView? |
| 17 | private var watchTimer: Timer? |
| 18 | private var onClose: (() -> Void)? |
| 19 | |
| 20 | static func show(under anchor: NSView, color: NSColor, |
| 21 | onChange: @escaping (NSColor) -> Void, |
| 22 | onClose: (() -> Void)? = nil) { |
| 23 | if let cur = current, cur.isVisible, cur.anchorView === anchor { return } |
| 24 | current?.dismiss() |
| 25 | guard let window = anchor.window else { return } |
| 26 | let size = NSSize(width: 232, height: 268) |
| 27 | let anchorRect = window.convertToScreen(anchor.convert(anchor.bounds, to: nil)) |
| 28 | // Opens ABOVE the swatch (presets sit at the bottom, nearest the |
| 29 | // mouse); falls back to below when there's no room. |
| 30 | var origin = NSPoint(x: anchorRect.midX - size.width / 2, |
| 31 | y: anchorRect.maxY + 6) |
| 32 | if let screen = window.screen { |
| 33 | origin.x = min(max(origin.x, screen.visibleFrame.minX + 8), |
| 34 | screen.visibleFrame.maxX - size.width - 8) |
| 35 | if origin.y + size.height > screen.visibleFrame.maxY { |
| 36 | origin.y = anchorRect.minY - size.height - 6 |
| 37 | } |
| 38 | } |
| 39 | let panel = ColorPickerPanel( |
| 40 | contentRect: NSRect(origin: origin, size: size), |
| 41 | styleMask: [.borderless, .nonactivatingPanel], |
| 42 | backing: .buffered, defer: false) |
| 43 | panel.isOpaque = false |
| 44 | panel.backgroundColor = .clear |
| 45 | panel.level = .popUpMenu |
| 46 | panel.isReleasedWhenClosed = false |
| 47 | panel.hidesOnDeactivate = true |
| 48 | panel.anchorView = anchor |
| 49 | panel.onClose = onClose |
| 50 | let view = ColorPickerView(frame: NSRect(origin: .zero, size: size)) |
| 51 | view.setColor(color) |
| 52 | view.onChange = onChange |
| 53 | panel.contentView = view |
| 54 | panel.orderFront(nil) |
| 55 | current = panel |
| 56 | panel.startWatchingMouse() |
| 57 | } |
| 58 | |
| 59 | /// Close the moment the pointer is neither on the panel, the anchor |
| 60 | /// swatch, nor the small corridor between them. |
| 61 | private func startWatchingMouse() { |
| 62 | watchTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in |
| 63 | guard let self else { return } |
| 64 | let mouse = NSEvent.mouseLocation |
| 65 | var keep = self.frame.insetBy(dx: -3, dy: -3).contains(mouse) |
| 66 | if let anchor = self.anchorView, let win = anchor.window { |
| 67 | let r = win.convertToScreen(anchor.convert(anchor.bounds, to: nil)) |
| 68 | keep = keep || r.insetBy(dx: -3, dy: -3).contains(mouse) |
| 69 | // Corridor between the swatch and the panel. |
| 70 | let lo = min(r.maxY, self.frame.minY), hi = max(r.minY, self.frame.maxY) |
| 71 | if mouse.x >= r.minX - 3, mouse.x <= r.maxX + 3, |
| 72 | mouse.y >= lo, mouse.y <= hi { |
| 73 | keep = true |
| 74 | } |
| 75 | } |
| 76 | if !keep { self.dismiss() } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /// Hovering any OTHER toolbar button kills the picker instantly. |
| 81 | static func close(unlessAnchor view: NSView?) { |
| 82 | if let cur = current, cur.anchorView !== view { cur.dismiss() } |
| 83 | } |
| 84 | |
| 85 | fileprivate func dismiss() { |
| 86 | watchTimer?.invalidate() |
| 87 | watchTimer = nil |
| 88 | close() |
| 89 | if Self.current === self { Self.current = nil } |
| 90 | let cb = onClose |
| 91 | onClose = nil |
| 92 | cb?() |
| 93 | } |
| 94 | |
| 95 | override func cancelOperation(_ sender: Any?) { dismiss() } |
| 96 | } |
| 97 | |
| 98 | final class ColorPickerView: NSView { |
| 99 | var onChange: ((NSColor) -> Void)? |
| 100 | |
| 101 | // Model: hue 0..1, plus barycentric coords in the SV triangle: |
| 102 | // (pure-hue weight, white weight, black weight). |
| 103 | private var hue: CGFloat = 0 |
| 104 | private var wHue: CGFloat = 1 |
| 105 | private var wWhite: CGFloat = 0 |
| 106 | |
| 107 | private var triangleImage: NSImage? |
| 108 | private var triangleHue: CGFloat = -1 |
| 109 | |
| 110 | private let outerR: CGFloat = 106 |
| 111 | private let ringWidth: CGFloat = 22 |
| 112 | private var innerR: CGFloat { outerR - ringWidth } |
| 113 | private var wheelCenter: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY + 16) } |
| 114 | |
| 115 | private enum DragTarget { case none, ring, triangle } |
| 116 | private var dragging: DragTarget = .none |
| 117 | |
| 118 | func setColor(_ c: NSColor) { |
| 119 | let hsb = (c.usingColorSpace(.deviceRGB) ?? c) |
| 120 | hue = hsb.hueComponent |
| 121 | let b = hsb.brightnessComponent, s = hsb.saturationComponent |
| 122 | wHue = b * s |
| 123 | wWhite = b * (1 - s) |
| 124 | triangleHue = -1 |
| 125 | needsDisplay = true |
| 126 | } |
| 127 | |
| 128 | private var currentColor: NSColor { |
| 129 | let pure = NSColor(calibratedHue: hue, saturation: 1, brightness: 1, alpha: 1) |
| 130 | return NSColor(calibratedRed: min(1, pure.redComponent * wHue + wWhite), |
| 131 | green: min(1, pure.greenComponent * wHue + wWhite), |
| 132 | blue: min(1, pure.blueComponent * wHue + wWhite), |
| 133 | alpha: 1) |
| 134 | } |
| 135 | |
| 136 | // Triangle vertices: pure hue at the top, white lower-left, black lower-right. |
| 137 | private var triVerts: [NSPoint] { |
| 138 | let r = innerR - 6 |
| 139 | let c = wheelCenter |
| 140 | func pt(_ deg: CGFloat) -> NSPoint { |
| 141 | NSPoint(x: c.x + r * cos(deg * .pi / 180), y: c.y + r * sin(deg * .pi / 180)) |
| 142 | } |
| 143 | return [pt(90), pt(210), pt(330)] // hue, white, black |
| 144 | } |
| 145 | |
| 146 | override func draw(_ dirtyRect: NSRect) { |
| 147 | // Backing card |
| 148 | let card = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), |
| 149 | xRadius: 10, yRadius: 10) |
| 150 | (Theme.light ? NSColor(calibratedWhite: 0.97, alpha: 0.98) |
| 151 | : NSColor(calibratedWhite: 0.14, alpha: 0.97)).setFill() |
| 152 | card.fill() |
| 153 | (Theme.light ? NSColor(calibratedWhite: 0.7, alpha: 1) |
| 154 | : NSColor(calibratedWhite: 0.35, alpha: 1)).setStroke() |
| 155 | card.lineWidth = 1 |
| 156 | card.stroke() |
| 157 | |
| 158 | drawHueRing() |
| 159 | drawTriangle() |
| 160 | drawIndicators() |
| 161 | drawPresets() |
| 162 | } |
| 163 | |
| 164 | private func drawHueRing() { |
| 165 | let c = wheelCenter |
| 166 | let midR = (outerR + innerR) / 2 |
| 167 | let steps = 180 |
| 168 | for i in 0..<steps { |
| 169 | let a0 = CGFloat(i) / CGFloat(steps) * 360 |
| 170 | let a1 = CGFloat(i + 1) / CGFloat(steps) * 360 + 0.8 |
| 171 | let path = NSBezierPath() |
| 172 | path.appendArc(withCenter: c, radius: midR, startAngle: a0, endAngle: a1) |
| 173 | path.lineWidth = ringWidth |
| 174 | NSColor(calibratedHue: a0 / 360, saturation: 1, brightness: 1, alpha: 1).setStroke() |
| 175 | path.stroke() |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | private func triangleBitmap(for hue: CGFloat) -> NSImage { |
| 180 | let verts = triVerts |
| 181 | let minX = verts.map(\.x).min()!, maxX = verts.map(\.x).max()! |
| 182 | let minY = verts.map(\.y).min()!, maxY = verts.map(\.y).max()! |
| 183 | let w = Int(ceil(maxX - minX)), h = Int(ceil(maxY - minY)) |
| 184 | let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: w, pixelsHigh: h, |
| 185 | bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, |
| 186 | isPlanar: false, colorSpaceName: .calibratedRGB, |
| 187 | bytesPerRow: w * 4, bitsPerPixel: 32)! |
| 188 | let pure = NSColor(calibratedHue: hue, saturation: 1, brightness: 1, alpha: 1) |
| 189 | let pr = pure.redComponent, pg = pure.greenComponent, pb = pure.blueComponent |
| 190 | // Local (bitmap) vertex coords; bitmap y grows downward. |
| 191 | let v = verts.map { CGPoint(x: $0.x - minX, y: maxY - $0.y) } |
| 192 | let denom = (v[1].y - v[2].y) * (v[0].x - v[2].x) |
| 193 | + (v[2].x - v[1].x) * (v[0].y - v[2].y) |
| 194 | guard let data = rep.bitmapData, abs(denom) > 0.0001 else { return NSImage() } |
| 195 | for py in 0..<h { |
| 196 | for px in 0..<w { |
| 197 | let p = CGPoint(x: CGFloat(px) + 0.5, y: CGFloat(py) + 0.5) |
| 198 | var a = ((v[1].y - v[2].y) * (p.x - v[2].x) |
| 199 | + (v[2].x - v[1].x) * (p.y - v[2].y)) / denom |
| 200 | var b = ((v[2].y - v[0].y) * (p.x - v[2].x) |
| 201 | + (v[0].x - v[2].x) * (p.y - v[2].y)) / denom |
| 202 | var cc = 1 - a - b |
| 203 | let inside = a >= -0.02 && b >= -0.02 && cc >= -0.02 |
| 204 | let o = (py * w + px) * 4 |
| 205 | if inside { |
| 206 | a = max(0, a); b = max(0, b); cc = max(0, cc) |
| 207 | let sum = a + b + cc |
| 208 | a /= sum; b /= sum |
| 209 | data[o] = UInt8(min(255, (pr * a + b) * 255)) |
| 210 | data[o + 1] = UInt8(min(255, (pg * a + b) * 255)) |
| 211 | data[o + 2] = UInt8(min(255, (pb * a + b) * 255)) |
| 212 | data[o + 3] = 255 |
| 213 | } else { |
| 214 | data[o] = 0; data[o + 1] = 0; data[o + 2] = 0; data[o + 3] = 0 |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | let img = NSImage(size: NSSize(width: w, height: h)) |
| 219 | img.addRepresentation(rep) |
| 220 | return img |
| 221 | } |
| 222 | |
| 223 | private func drawTriangle() { |
| 224 | if triangleHue != hue { |
| 225 | triangleImage = triangleBitmap(for: hue) |
| 226 | triangleHue = hue |
| 227 | } |
| 228 | guard let img = triangleImage else { return } |
| 229 | let verts = triVerts |
| 230 | let minX = verts.map(\.x).min()!, maxY = verts.map(\.y).max()! |
| 231 | img.draw(at: NSPoint(x: minX, y: maxY - img.size.height), |
| 232 | from: .zero, operation: .sourceOver, fraction: 1) |
| 233 | } |
| 234 | |
| 235 | private func drawIndicators() { |
| 236 | // Hue marker on the ring |
| 237 | let c = wheelCenter |
| 238 | let a = hue * 2 * .pi |
| 239 | let midR = (outerR + innerR) / 2 |
| 240 | let hp = NSPoint(x: c.x + midR * cos(a), y: c.y + midR * sin(a)) |
| 241 | let ring = NSBezierPath(ovalIn: NSRect(x: hp.x - 6, y: hp.y - 6, width: 12, height: 12)) |
| 242 | NSColor.white.setStroke() |
| 243 | ring.lineWidth = 2.5 |
| 244 | ring.stroke() |
| 245 | NSColor.black.withAlphaComponent(0.6).setStroke() |
| 246 | let ring2 = NSBezierPath(ovalIn: NSRect(x: hp.x - 7.5, y: hp.y - 7.5, width: 15, height: 15)) |
| 247 | ring2.lineWidth = 1 |
| 248 | ring2.stroke() |
| 249 | |
| 250 | // SV marker in the triangle |
| 251 | let v = triVerts |
| 252 | let wBlack = max(0, 1 - wHue - wWhite) |
| 253 | let p = NSPoint(x: v[0].x * wHue + v[1].x * wWhite + v[2].x * wBlack, |
| 254 | y: v[0].y * wHue + v[1].y * wWhite + v[2].y * wBlack) |
| 255 | let dot = NSBezierPath(ovalIn: NSRect(x: p.x - 5, y: p.y - 5, width: 10, height: 10)) |
| 256 | currentColor.setFill() |
| 257 | dot.fill() |
| 258 | NSColor.white.setStroke() |
| 259 | dot.lineWidth = 2 |
| 260 | dot.stroke() |
| 261 | } |
| 262 | |
| 263 | private func presetRect(_ i: Int) -> NSRect { |
| 264 | let n = Palette.colors.count |
| 265 | let w: CGFloat = 20, gap: CGFloat = 6 |
| 266 | let total = CGFloat(n) * w + CGFloat(n - 1) * gap |
| 267 | let x0 = bounds.midX - total / 2 |
| 268 | return NSRect(x: x0 + CGFloat(i) * (w + gap), y: 12, width: w, height: 20) |
| 269 | } |
| 270 | |
| 271 | private func drawPresets() { |
| 272 | for (i, c) in Palette.colors.enumerated() { |
| 273 | let r = presetRect(i) |
| 274 | c.setFill() |
| 275 | let p = NSBezierPath(roundedRect: r, xRadius: 5, yRadius: 5) |
| 276 | p.fill() |
| 277 | NSColor(calibratedWhite: 0.5, alpha: 0.8).setStroke() |
| 278 | p.lineWidth = 1 |
| 279 | p.stroke() |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // MARK: Interaction |
| 284 | |
| 285 | private func hitTarget(_ p: NSPoint) -> DragTarget { |
| 286 | let c = wheelCenter |
| 287 | let d = hypot(p.x - c.x, p.y - c.y) |
| 288 | if d >= innerR - 2, d <= outerR + 4 { return .ring } |
| 289 | if d < innerR { return .triangle } |
| 290 | return .none |
| 291 | } |
| 292 | |
| 293 | override func mouseDown(with event: NSEvent) { |
| 294 | let p = convert(event.locationInWindow, from: nil) |
| 295 | for (i, c) in Palette.colors.enumerated() |
| 296 | where presetRect(i).insetBy(dx: -2, dy: -2).contains(p) { |
| 297 | setColor(c) |
| 298 | onChange?(currentColor) |
| 299 | needsDisplay = true |
| 300 | return |
| 301 | } |
| 302 | dragging = hitTarget(p) |
| 303 | apply(p) |
| 304 | } |
| 305 | |
| 306 | override func mouseDragged(with event: NSEvent) { |
| 307 | apply(convert(event.locationInWindow, from: nil)) |
| 308 | } |
| 309 | |
| 310 | override func mouseUp(with event: NSEvent) { dragging = .none } |
| 311 | |
| 312 | private func apply(_ p: NSPoint) { |
| 313 | switch dragging { |
| 314 | case .ring: |
| 315 | let c = wheelCenter |
| 316 | var a = atan2(p.y - c.y, p.x - c.x) |
| 317 | if a < 0 { a += 2 * .pi } |
| 318 | hue = a / (2 * .pi) |
| 319 | case .triangle: |
| 320 | let v = triVerts |
| 321 | let denom = (v[1].y - v[2].y) * (v[0].x - v[2].x) |
| 322 | + (v[2].x - v[1].x) * (v[0].y - v[2].y) |
| 323 | guard abs(denom) > 0.0001 else { return } |
| 324 | var a = ((v[1].y - v[2].y) * (p.x - v[2].x) |
| 325 | + (v[2].x - v[1].x) * (p.y - v[2].y)) / denom |
| 326 | var b = ((v[2].y - v[0].y) * (p.x - v[2].x) |
| 327 | + (v[0].x - v[2].x) * (p.y - v[2].y)) / denom |
| 328 | a = min(max(a, 0), 1) |
| 329 | b = min(max(b, 0), 1) |
| 330 | let cc = max(0, 1 - a - b) |
| 331 | let sum = a + b + cc |
| 332 | wHue = a / sum |
| 333 | wWhite = b / sum |
| 334 | case .none: |
| 335 | return |
| 336 | } |
| 337 | onChange?(currentColor) |
| 338 | needsDisplay = true |
| 339 | } |
| 340 | } |