| 1 | import 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. |
| 5 | enum 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 | |
| 57 | final 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 | |
| 100 | final 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 | |
| 213 | final 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 | } |