1import AppKit
2
3/// Tools for the MAIN window's toolbar: editing tools work on the timeline,
4/// drawing tools work directly on storyboard preview cells.
5enum MainTool: CaseIterable {
6 case select, blade, slide, pencil, pen, thick, eraser
7
8 var label: String {
9 switch self {
10 case .select: return "Select"
11 case .blade: return "Blade"
12 case .slide: return "Slide"
13 case .pencil: return "Pencil"
14 case .pen: return "Pen"
15 case .thick: return "Thick Pen"
16 case .eraser: return "Eraser"
17 }
18 }
19 /// Single-key shortcut shown in the toolbar tooltip (nil = no binding).
20 var shortcut: String? {
21 switch self {
22 case .select: return "V"
23 default: return nil
24 }
25 }
26 /// Tooltip label with the shortcut appended when there is one.
27 var tip: String { shortcut.map { "\(label) (\($0))" } ?? label }
28
29 var symbol: String {
30 switch self {
31 case .select: return "cursorarrow"
32 case .blade: return "scissors"
33 case .slide: return "arrow.left.and.right.square"
34 case .pencil: return "pencil"
35 case .pen: return "pencil.tip"
36 case .thick: return "paintbrush.pointed.fill"
37 case .eraser: return "eraser"
38 }
39 }
40 var strokeWidth: CGFloat? {
41 switch self {
42 case .pencil: return 2
43 case .pen: return 4.5
44 case .thick: return 11
45 case .eraser: return 26
46 default: return nil
47 }
48 }
49 var isDraw: Bool { strokeWidth != nil }
50}
51
52// `mainTool`, `drawColor`, `pendingShape`, and `panelUnderPlayhead` moved to
53// `SessionState` (per-window). See SessionState.swift.
54
55// MARK: - Radial quick picker (right-click on a drawing canvas)
56
57final class RadialPicker: NSPanel {
58 private static var current: RadialPicker?
59
60 static func show(at screenPoint: NSPoint, currentTool: BoardTool,
61 currentColor: NSColor,
62 onTool: @escaping (BoardTool) -> Void,
63 onColor: @escaping (NSColor) -> Void) {
64 current?.close()
65 let size: CGFloat = 230
66 let panel = RadialPicker(
67 contentRect: NSRect(x: screenPoint.x - size / 2,
68 y: screenPoint.y - size / 2,
69 width: size, height: size),
70 styleMask: [.borderless, .nonactivatingPanel],
71 backing: .buffered, defer: false)
72 panel.isOpaque = false
73 panel.backgroundColor = .clear
74 panel.level = .popUpMenu
75 panel.hidesOnDeactivate = true
76 panel.isReleasedWhenClosed = false
77 let view = RadialView(frame: NSRect(x: 0, y: 0, width: size, height: size))
78 view.currentTool = currentTool
79 view.currentColor = currentColor
80 view.onPick = { tool, color in
81 if let tool { onTool(tool) }
82 if let color { onColor(color) }
83 panel.close()
84 current = nil
85 }
86 view.onDismiss = { panel.close(); current = nil }
87 panel.contentView = view
88 panel.makeKeyAndOrderFront(nil)
89 current = panel
90 }
91
92 override var canBecomeKey: Bool { true }
93 override func resignKey() {
94 super.resignKey()
95 close()
96 }
97 override func cancelOperation(_ sender: Any?) { close() }
98}
99
100final class RadialView: NSView {
101 var currentTool: BoardTool = .pencil
102 var currentColor: NSColor = .black
103 var onPick: ((BoardTool?, NSColor?) -> Void)?
104 var onDismiss: (() -> Void)?
105 private var hoverIndex: (ring: Int, index: Int)? // ring 0 = tools, 1 = colors
106
107 static let tools: [BoardTool] = [.select, .pencil, .pen, .thick, .eraser,
108 .rect, .oval, .triangle, .star, .ngon,
109 .text, .image]
110
111 private var center: NSPoint { NSPoint(x: bounds.midX, y: bounds.midY) }
112 private let toolRadius: CGFloat = 88
113 private let colorRadius: CGFloat = 46
114
115 override init(frame: NSRect) {
116 super.init(frame: frame)
117 addTrackingArea(NSTrackingArea(
118 rect: frame, options: [.mouseMoved, .activeAlways, .inVisibleRect],
119 owner: self, userInfo: nil))
120 }
121 required init?(coder: NSCoder) { fatalError() }
122
123 private func toolPoint(_ i: Int) -> NSPoint {
124 let a = -CGFloat.pi / 2 + CGFloat(i) * 2 * .pi / CGFloat(Self.tools.count)
125 return NSPoint(x: center.x + toolRadius * cos(a), y: center.y + toolRadius * sin(a))
126 }
127 private func colorPoint(_ i: Int) -> NSPoint {
128 let a = -CGFloat.pi / 2 + CGFloat(i) * 2 * .pi / CGFloat(Palette.colors.count)
129 return NSPoint(x: center.x + colorRadius * cos(a), y: center.y + colorRadius * sin(a))
130 }
131
132 private func hit(_ p: NSPoint) -> (ring: Int, index: Int)? {
133 for (i, _) in Self.tools.enumerated()
134 where hypot(toolPoint(i).x - p.x, toolPoint(i).y - p.y) < 15 {
135 return (0, i)
136 }
137 for (i, _) in Palette.colors.enumerated()
138 where hypot(colorPoint(i).x - p.x, colorPoint(i).y - p.y) < 11 {
139 return (1, i)
140 }
141 return nil
142 }
143
144 override func draw(_ dirtyRect: NSRect) {
145 // Backing disc
146 NSColor(calibratedWhite: Theme.light ? 0.95 : 0.14, alpha: 0.94).setFill()
147 let disc = NSBezierPath(ovalIn: NSRect(x: center.x - 112, y: center.y - 112,
148 width: 224, height: 224))
149 disc.fill()
150 NSColor(calibratedWhite: Theme.light ? 0.6 : 0.35, alpha: 1).setStroke()
151 disc.lineWidth = 1
152 disc.stroke()
153
154 for (i, tool) in Self.tools.enumerated() {
155 let p = toolPoint(i)
156 let selected = tool == currentTool
157 let hovered = hoverIndex?.ring == 0 && hoverIndex?.index == i
158 if selected || hovered {
159 (selected ? NSColor.controlAccentColor
160 : NSColor(calibratedWhite: 0.35, alpha: 1))
161 .withAlphaComponent(0.9).setFill()
162 NSBezierPath(ovalIn: NSRect(x: p.x - 14, y: p.y - 14,
163 width: 28, height: 28)).fill()
164 }
165 if let img = NSImage(systemSymbolName: tool.symbol,
166 accessibilityDescription: tool.label) {
167 img.tinted(selected || hovered ? .white
168 : NSColor(calibratedWhite: Theme.light ? 0.25 : 0.8, alpha: 1))
169 .draw(in: NSRect(x: p.x - 8, y: p.y - 8, width: 16, height: 16),
170 from: .zero, operation: .sourceOver, fraction: 1,
171 respectFlipped: true, hints: nil)
172 }
173 }
174 for (i, color) in Palette.colors.enumerated() {
175 let p = colorPoint(i)
176 let hovered = hoverIndex?.ring == 1 && hoverIndex?.index == i
177 color.setFill()
178 let r: CGFloat = hovered ? 10 : 8
179 NSBezierPath(ovalIn: NSRect(x: p.x - r, y: p.y - r,
180 width: r * 2, height: r * 2)).fill()
181 if color == currentColor || hovered {
182 NSColor.white.setStroke()
183 let ring = NSBezierPath(ovalIn: NSRect(x: p.x - r - 1.5, y: p.y - r - 1.5,
184 width: r * 2 + 3, height: r * 2 + 3))
185 ring.lineWidth = 1.5
186 ring.stroke()
187 }
188 }
189 }
190
191 override func mouseMoved(with event: NSEvent) {
192 hoverIndex = hit(convert(event.locationInWindow, from: nil))
193 needsDisplay = true
194 }
195
196 override func mouseDown(with event: NSEvent) {
197 let p = convert(event.locationInWindow, from: nil)
198 guard let h = hit(p) else {
199 onDismiss?()
200 return
201 }
202 if h.ring == 0 { onPick?(Self.tools[h.index], nil) }
203 else { onPick?(nil, Palette.colors[h.index]) }
204 }
205
206 override func keyDown(with event: NSEvent) {
207 if event.keyCode == 53 { onDismiss?() } // esc
208 }
209}
210
211// MARK: - Settings (⌘,) — Project tab + Global tab
212
213final class SettingsWindow: NSObject {
214 static let shared = SettingsWindow()
215 private var window: NSWindow?
216
217 // Project tab
218 private let fpsPopup = NSPopUpButton()
219 private let aspectPopup = NSPopUpButton()
220 private let compsLabel = NSTextField(labelWithString: "—")
221 // Global tab
222 private let cacheField = NSTextField(string: "")
223 private let ramField = NSTextField(string: "")
224 private let usageLabel = NSTextField(labelWithString: "")
225 private let cacheLabel = NSTextField(labelWithString: "")
226
227 // Aspect label → concrete storyboard resolution (stored in the model as
228 // pixels, shown here as a ratio).
229 static let boardAspects: [(label: String, width: Int, height: Int)] = [
230 ("16 : 9", 1920, 1080), ("4 : 3", 1440, 1080), ("1.85 : 1", 1998, 1080),
231 ("2.39 : 1", 2048, 858), ("1 : 1", 1080, 1080), ("9 : 16", 1080, 1920),
232 ]
233
234 func show() {
235 buildIfNeeded()
236 window?.makeKeyAndOrderFront(nil) // visible first — sync() guards on it
237 NSApp.activate(ignoringOtherApps: true)
238 sync()
239 }
240
241 private func label(_ s: String) -> NSTextField {
242 let l = NSTextField(labelWithString: s)
243 l.alignment = .right
244 return l
245 }
246
247 /// Key (fields need focus) but never MAIN: `DocumentContext.current`
248 /// resolves through the main window, so a main Settings window would read
249 /// a blank headless project (fps/comps/estimate all defaults) AND starve
250 /// the front-document build gate while it's open.
251 private final class SettingsPanel: NSWindow {
252 override var canBecomeMain: Bool { false }
253 }
254
255 private func buildIfNeeded() {
256 guard window == nil else { return }
257 let w = SettingsPanel(
258 contentRect: NSRect(x: 0, y: 0, width: 560, height: 300),
259 styleMask: [.titled, .closable],
260 backing: .buffered, defer: false)
261 w.title = "Settings"
262 w.isReleasedWhenClosed = false
263 w.center()
264
265 let tabs = NSTabView()
266 tabs.translatesAutoresizingMaskIntoConstraints = false
267
268 // ---- Project tab (travels with the .sq file) ----
269 fpsPopup.target = self
270 fpsPopup.action = #selector(fpsChanged)
271
272 aspectPopup.removeAllItems()
273 for a in Self.boardAspects { aspectPopup.addItem(withTitle: a.label) }
274 aspectPopup.target = self
275 aspectPopup.action = #selector(aspectChanged)
276
277 compsLabel.lineBreakMode = .byTruncatingMiddle
278 compsLabel.textColor = .secondaryLabelColor
279 let choose = NSButton(title: "Choose…", target: self, action: #selector(chooseComps))
280 choose.controlSize = .small
281 let clear = NSButton(title: "Clear", target: self, action: #selector(clearComps))
282 clear.controlSize = .small
283 let rescan = NSButton(title: "Rescan", target: self, action: #selector(rescanComps))
284 rescan.controlSize = .small
285 let compsRow = NSStackView(views: [choose, clear, rescan])
286 compsRow.spacing = 6
287
288 let aspectNote = NSTextField(labelWithString: "New storyboard panels use this shape.")
289 aspectNote.textColor = .secondaryLabelColor
290 aspectNote.font = .systemFont(ofSize: 11)
291
292 let projectGrid = NSGridView(views: [
293 [label("Frame rate"), fpsPopup],
294 [label("Storyboard aspect"), aspectPopup],
295 [NSView(), aspectNote],
296 [label("Comps folder"), compsLabel],
297 [NSView(), compsRow],
298 ])
299 projectGrid.rowSpacing = 10
300 projectGrid.column(at: 0).xPlacement = .trailing
301 projectGrid.column(at: 0).width = 140
302 let projectTab = NSTabViewItem(identifier: "project")
303 projectTab.label = "Project"
304 projectTab.view = wrap(projectGrid)
305 tabs.addTabViewItem(projectTab)
306
307 // ---- Global tab (this Mac, every project) ----
308 cacheField.target = self
309 cacheField.action = #selector(cacheChanged)
310 cacheField.widthAnchor.constraint(equalToConstant: 60).isActive = true
311 let cacheRow = NSStackView(views: [cacheField, NSTextField(labelWithString: "GB")])
312 cacheRow.spacing = 4
313 ramField.target = self
314 ramField.action = #selector(ramChanged)
315 ramField.widthAnchor.constraint(equalToConstant: 60).isActive = true
316 let ramRow = NSStackView(views: [ramField, NSTextField(labelWithString: "GB")])
317 ramRow.spacing = 4
318 let ramNote = NSTextField(labelWithString:
319 "Decoded stand-in frames + player read-ahead; more = fewer hiccups.")
320 ramNote.textColor = .secondaryLabelColor
321 ramNote.font = .systemFont(ofSize: 11)
322 let reveal = NSButton(title: "Reveal Cache", target: self, action: #selector(revealCache))
323 reveal.controlSize = .small
324 usageLabel.textColor = .secondaryLabelColor
325 usageLabel.font = .systemFont(ofSize: 11)
326 cacheLabel.textColor = .secondaryLabelColor
327 cacheLabel.font = .systemFont(ofSize: 11)
328
329 let globalGrid = NSGridView(views: [
330 [label("Proxy cache limit"), cacheRow],
331 [label("RAM frame cache"), ramRow],
332 [NSView(), ramNote],
333 [NSView(), usageLabel],
334 [NSView(), reveal],
335 [NSView(), cacheLabel],
336 ])
337 globalGrid.rowSpacing = 10
338 globalGrid.column(at: 0).xPlacement = .trailing
339 globalGrid.column(at: 0).width = 140
340 let globalTab = NSTabViewItem(identifier: "global")
341 globalTab.label = "Global"
342 globalTab.view = wrap(globalGrid)
343 tabs.addTabViewItem(globalTab)
344
345 w.contentView?.addSubview(tabs)
346 NSLayoutConstraint.activate([
347 tabs.topAnchor.constraint(equalTo: w.contentView!.topAnchor, constant: 12),
348 tabs.leadingAnchor.constraint(equalTo: w.contentView!.leadingAnchor, constant: 12),
349 tabs.trailingAnchor.constraint(equalTo: w.contentView!.trailingAnchor, constant: -12),
350 tabs.bottomAnchor.constraint(equalTo: w.contentView!.bottomAnchor, constant: -12),
351 ])
352 window = w
353 NotificationCenter.default.addObserver(self, selector: #selector(sync),
354 name: .projectChanged, object: nil)
355 }
356
357 private func wrap(_ grid: NSGridView) -> NSView {
358 let v = NSView()
359 grid.translatesAutoresizingMaskIntoConstraints = false
360 v.addSubview(grid)
361 NSLayoutConstraint.activate([
362 grid.topAnchor.constraint(equalTo: v.topAnchor, constant: 18),
363 grid.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 18),
364 grid.trailingAnchor.constraint(lessThanOrEqualTo: v.trailingAnchor, constant: -18),
365 ])
366 return v
367 }
368
369 @objc private func sync() {
370 // The estimate below walks each media's chunk dir — fine on demand,
371 // wasteful on every .projectChanged while the window is closed.
372 guard let window, window.isVisible else { return }
373 let project = DocumentContext.current.store.project
374 // Rebuild the fps popup: presets plus the project's own rate when it's
375 // not a preset (e.g. 29.50 fps probed from a screen recording).
376 fpsPopup.removeAllItems()
377 for (title, _) in AppDelegate.frameRates { fpsPopup.addItem(withTitle: title) }
378 if let i = AppDelegate.frameRates.firstIndex(where: { abs($0.1 - project.fps) < 0.01 }) {
379 fpsPopup.selectItem(at: i)
380 } else {
381 fpsPopup.addItem(withTitle: String(format: "%.4g fps (current)", project.fps))
382 fpsPopup.selectItem(at: fpsPopup.numberOfItems - 1)
383 }
384 if let i = Self.boardAspects.firstIndex(where: {
385 abs(Double($0.width) / Double($0.height) - project.boardAspect) < 0.01
386 }) {
387 aspectPopup.selectItem(at: i)
388 }
389 compsLabel.stringValue = project.compsFolder ?? "not set"
390 let gb = UserDefaults.standard.integer(forKey: "maxCacheGB")
391 cacheField.stringValue = "\(gb > 0 ? gb : 50)"
392 ramField.stringValue = "\(FrameCache.ramGB)"
393 let pipeline = MediaPipeline.shared
394 let est = DocumentContext.current.chunks.optimizeEstimate(for: project)
395 usageLabel.stringValue = String(
396 format: "Using %.1f of %.0f GB · this project fully optimized ≈ %.1f GB (%.1f GB built)",
397 Double(pipeline.ledgerBytes) / 1e9, Double(pipeline.maxCacheBytes) / 1e9,
398 Double(est.total) / 1e9, Double(est.built) / 1e9)
399 cacheLabel.stringValue = "Cache: \(pipeline.cacheRoot.path)"
400 }
401
402 @objc private func fpsChanged() {
403 let i = fpsPopup.indexOfSelectedItem
404 guard i >= 0, i < AppDelegate.frameRates.count else { return }
405 DocumentContext.current.store.mutate { $0.fps = AppDelegate.frameRates[i].1 }
406 }
407 @objc private func aspectChanged() {
408 let i = aspectPopup.indexOfSelectedItem
409 guard i >= 0, i < Self.boardAspects.count else { return }
410 let a = Self.boardAspects[i]
411 DocumentContext.current.store.mutate { $0.boardWidth = a.width; $0.boardHeight = a.height }
412 }
413 @objc private func chooseComps() {
414 let panel = NSOpenPanel()
415 panel.canChooseDirectories = true
416 panel.canChooseFiles = false
417 panel.prompt = "Use as Comps Folder"
418 guard panel.runModal() == .OK, let url = panel.url else { return }
419 DocumentContext.current.store.mutate { $0.compsFolder = url.path }
420 DocumentContext.current.comps.rescan()
421 }
422 @objc private func clearComps() {
423 DocumentContext.current.store.mutate { $0.compsFolder = nil }
424 }
425 @objc private func rescanComps() { DocumentContext.current.comps.rescan() }
426 @objc private func cacheChanged() {
427 let gb = Int(cacheField.stringValue) ?? 50
428 UserDefaults.standard.set(max(1, gb), forKey: "maxCacheGB")
429 MediaPipeline.shared.evictIfNeeded(reconcile: true)
430 sync()
431 }
432 @objc private func ramChanged() {
433 let gb = Int(ramField.stringValue) ?? 2
434 UserDefaults.standard.set(max(1, gb), forKey: "maxRAMGB")
435 FrameCache.shared.refreshBudget()
436 sync()
437 }
438 @objc private func revealCache() {
439 NSWorkspace.shared.activateFileViewerSelecting([MediaPipeline.shared.cacheRoot])
440 }
441}