1import AppKit
2
3/// The shared 8-color quick palette (toolbar picker, radial picker).
4enum 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.
14final 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
98final 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}