1import AppKit
2
3/// Dev-only per-section draw profiler (enabled by the `--perftest` harness).
4/// Zero overhead when `on` is false.
5enum DrawProf {
6 static var on = false
7 static var acc: [String: Double] = [:]
8 static var order: [String] = []
9 static var thumbHits = 0 // filmstrip images actually blitted (cache hits)
10 static var tileRenders = 0 // scene tiles rasterized (cache misses)
11 static var tileBlits = 0 // scene tiles blitted (cache hits + fresh)
12 @inline(__always) static func t<T>(_ label: String, _ body: () -> T) -> T {
13 // Main-thread only: lanes rasterize in parallel, and racing the
14 // accumulator dictionaries would crash. Off-main work is simply
15 // not attributed (the enclosing main-thread section still is).
16 if !on || !Thread.isMainThread { return body() }
17 let t0 = DispatchTime.now().uptimeNanoseconds
18 let r = body()
19 let dt = Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6
20 if acc[label] == nil { order.append(label) }
21 acc[label, default: 0] += dt
22 return r
23 }
24 static func reset() { acc = [:]; order = [] }
25 static func report(frames: Int) {
26 for k in order {
27 print(String(format: " %-12@ %7.3f ms/frame", k as NSString,
28 acc[k]! / Double(frames)))
29 }
30 }
31}
32
33/// The timeline: ruler, Fusion comps band, track lanes, clips, playhead.
34/// Tracks are unnamed and color-coded; new tracks appear dynamically when a
35/// clip is dragged below the last lane (two rows down = two new tracks).
36/// Empty tracks are allowed. All edits are frame-quantized and run through
37/// Store gestures so every drag is one undo step.
38final class TimelineView: NSView {
39
40 // View state
41 private var pxPerSecond: Double = 20
42 private var originSecond: Double = -1
43 private var scrollY: CGFloat = 0 // vertical track scroll offset
44 private let rulerH: CGFloat = 26
45 private let baseLaneH: CGFloat = 64
46 private let laneGap: CGFloat = 4
47 private let headerW: CGFloat = 26
48
49 /// The document context this view belongs to — its store, playback clock,
50 /// comps scanner and storyboard rasters. A thin facade over the shared
51 /// singletons for now; becomes an injected per-document instance later.
52 var ctx: DocumentContext = .headless {
53 didSet {
54 guard oldValue !== ctx else { return }
55 // `.playheadChanged` is per-document (on ctx.notify) — re-point it
56 // when the document context is injected.
57 oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil)
58 ctx.notify.addObserver(self, selector: #selector(playheadMoved),
59 name: .playheadChanged, object: nil)
60 // New document ⇒ new model; the scene cache must rebuild even though
61 // no `.projectChanged` fires for the context swap itself.
62 sceneDirty = true
63 stripKey = nil
64 laneRefsCache = nil
65 flushTiles()
66 }
67 }
68 private var store: Store { ctx.store }
69 private var project: ProjectModel { ctx.store.project }
70
71 /// The model's `laneRefs` getter scans EVERY clip (`hasStoryboard`) —
72 /// O(clips). The draw path reaches it per clip drawn (`drawClip →
73 /// clipRect → laneRect`), which is quadratic on big projects: 98% CPU
74 /// redraw storms. Cache it; invalidated on `.projectChanged`/
75 /// `.viewOptionsChanged` (redraw()) and on document swap (ctx.didSet).
76 private var laneRefsCache: [TrackRef]?
77 private var laneRows: [TrackRef] {
78 if let rows = laneRefsCache { return rows }
79 let rows = project.laneRefs
80 laneRefsCache = rows
81 return rows
82 }
83 private var playback: PlaybackController { ctx.playback }
84 private var comps: FusionComps { ctx.comps }
85 private var boards: BoardStore { ctx.boards }
86 private var session: SessionState { ctx.session }
87
88 override var isFlipped: Bool { true }
89 override var acceptsFirstResponder: Bool { true }
90
91 // MARK: - Init
92
93 override init(frame: NSRect) {
94 super.init(frame: frame)
95 registerForDraggedTypes([.fileURL])
96 // Only model/view-option changes invalidate the grouped-clip scene
97 // cache. Selection changes just refresh the link-mate cache, and
98 // media-status/comps changes (thumbnails landing, comps rescans) only
99 // need a repaint — none of them regroup or re-sort thousands of clips.
100 for name: Notification.Name in [.projectChanged, .viewOptionsChanged] {
101 NotificationCenter.default.addObserver(self, selector: #selector(redraw),
102 name: name, object: nil)
103 }
104 NotificationCenter.default.addObserver(self, selector: #selector(selectionRedraw),
105 name: .selectionChanged, object: nil)
106 NotificationCenter.default.addObserver(self, selector: #selector(mediaRedraw(_:)),
107 name: .mediaStatusChanged, object: nil)
108 // The comps band isn't tiled — a comps rescan is repaint-only.
109 NotificationCenter.default.addObserver(self, selector: #selector(repaintOnly),
110 name: .compsChanged, object: nil)
111 // Per-document: bound against the current (headless) ctx here, re-bound
112 // when a real ctx is injected (see `ctx.didSet`).
113 ctx.notify.addObserver(self, selector: #selector(playheadMoved),
114 name: .playheadChanged, object: nil)
115 NotificationCenter.default.addObserver(self, selector: #selector(revealClip(_:)),
116 name: .revealClip, object: nil)
117 }
118
119 required init?(coder: NSCoder) { fatalError() }
120
121 @objc private func redraw() {
122 sceneDirty = true
123 stripKey = nil
124 laneRefsCache = nil
125 flushTiles()
126 needsDisplay = true
127 }
128
129 @objc private func repaintOnly() { needsDisplay = true }
130
131 /// `.mediaStatusChanged` is posted for two very different things: scene
132 /// pixels changing (a filmstrip/waveform/board raster landed — posts carry
133 /// `scene: true`) and proxy-chunk bookkeeping (status bar / viewer badges —
134 /// no timeline pixels). Only the former invalidates the tile cache; chunk
135 /// churn during playback must not force full scene re-renders.
136 @objc private func mediaRedraw(_ note: Notification) {
137 if note.userInfo?["scene"] != nil { flushTiles() }
138 stripKey = nil // chunk states changed — recompute the strip's runs
139 needsDisplay = true
140 }
141
142 @objc private func selectionRedraw() {
143 // Link-mates of the selection outline aqua; recompute just that set —
144 // a selection click must not regroup/re-sort the whole project.
145 if !sceneDirty {
146 linkedSelectionCache = project.expandLinks(store.selection)
147 .subtracting(store.selection)
148 }
149 flushTiles() // selection tint/borders are baked into the rasters
150 needsDisplay = true
151 }
152
153 @objc private func playheadMoved() {
154 // Auto-follow while playing.
155 if playback.isPlaying {
156 let x = xFor(playback.playhead)
157 if x > bounds.width - 60 || x < headerW {
158 originSecond = playback.playhead
159 - 0.1 * Double(bounds.width) / pxPerSecond
160 }
161 }
162 needsDisplay = true
163 }
164
165 /// Scroll so a clip is on screen (viewer cells post this on click).
166 @objc private func revealClip(_ note: Notification) {
167 guard let id = note.userInfo?["clipId"] as? UUID,
168 let clip = project.clip(id) else { return }
169 let x0 = xFor(clip.start), x1 = xFor(clip.end)
170 if x1 < headerW + 20 || x0 > bounds.width - 20 {
171 originSecond = clip.start - 0.15 * Double(bounds.width) / pxPerSecond
172 }
173 needsDisplay = true
174 }
175
176 // MARK: - Coordinates
177
178 private func xFor(_ seconds: Double) -> CGFloat {
179 headerW + CGFloat((seconds - originSecond) * pxPerSecond)
180 }
181 private func secondsFor(_ x: CGFloat) -> Double {
182 originSecond + Double(x - headerW) / pxPerSecond
183 }
184 private func quantize(_ seconds: Double) -> Double {
185 let fps = project.fps
186 return (seconds * fps).rounded() / fps
187 }
188 private var frameDur: Double { 1.0 / project.fps }
189
190 /// The optimization strip: a thin band under the ruler showing, per
191 /// timeline column, how optimized the media the visible tracks need there
192 /// is (see drawOptimizeStrip).
193 private let stripH: CGFloat = 5
194 private var fusionBandH: CGFloat { comps.visible ? 46 : 0 }
195 private var fusionTop: CGFloat { rulerH + stripH }
196 private var lanesTop: CGFloat { rulerH + stripH + fusionBandH }
197
198 private func laneHeight(_ ref: TrackRef) -> CGFloat {
199 // Device-pixel quantized so lane rasters (scene tiles) blit without
200 // resampling; the ≤ half-device-pixel rounding is imperceptible.
201 quantized(max(24, baseLaneH * session.laneScale * (session.trackHeights[ref] ?? 1)))
202 }
203 private var defaultLaneH: CGFloat { quantized(max(24, baseLaneH * session.laneScale)) }
204
205 /// Total height of all lanes (for vertical scroll clamping).
206 private var lanesContentHeight: CGFloat {
207 laneRows.reduce(laneGap) { $0 + laneHeight($1) + laneGap }
208 }
209 private var maxScrollY: CGFloat {
210 max(0, lanesContentHeight - (bounds.height - lanesTop) + defaultLaneH)
211 }
212
213 private func laneRect(row: Int) -> NSRect {
214 let rows = laneRows
215 var y = lanesTop + laneGap - scrollY
216 for (i, ref) in rows.enumerated() {
217 let h = laneHeight(ref)
218 if i == row {
219 return NSRect(x: 0, y: y, width: bounds.width, height: h)
220 }
221 y += h + laneGap
222 }
223 let extra = CGFloat(row - rows.count)
224 return NSRect(x: 0, y: y + extra * (defaultLaneH + laneGap),
225 width: bounds.width, height: defaultLaneH)
226 }
227
228 private func rowAt(y: CGFloat) -> Int? {
229 guard y > lanesTop else { return nil }
230 var yy = lanesTop + laneGap - scrollY
231 let rows = laneRows
232 for (i, ref) in rows.enumerated() {
233 let h = laneHeight(ref)
234 if y < yy + h + laneGap { return i }
235 yy += h + laneGap
236 }
237 return rows.count + max(0, Int((y - yy) / (defaultLaneH + laneGap)))
238 }
239
240 /// Row whose bottom edge is under the cursor (for track-height resizing).
241 private func trackBoundaryAt(y: CGFloat) -> Int? {
242 guard y > lanesTop else { return nil }
243 let rows = laneRows
244 var yy = lanesTop + laneGap - scrollY
245 for (i, ref) in rows.enumerated() {
246 yy += laneHeight(ref)
247 if abs(y - yy) <= 4 { return i }
248 yy += laneGap
249 }
250 return nil
251 }
252
253 private func clipRect(_ clip: Clip, row: Int) -> NSRect {
254 let lane = laneRect(row: row)
255 let x0 = xFor(clip.start), x1 = xFor(clip.end)
256 return NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height)
257 }
258
259 /// The lane shown at a row, or nil past the last real lane (ghost rows).
260 private func laneRef(row: Int) -> TrackRef? {
261 let rows = laneRows
262 return rows.indices.contains(row) ? rows[row] : nil
263 }
264
265 private func clipAt(point: NSPoint) -> (clip: Clip, row: Int)? {
266 guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil }
267 // The cached per-lane arrays are already start-sorted — no need to
268 // re-filter and re-sort the whole project per mouse event.
269 rebuildSceneIfNeeded()
270 // Later clips draw on top, so hit-test in reverse.
271 for clip in (clipsByLane[ref] ?? []).reversed() {
272 if clipRect(clip, row: row).contains(point) { return (clip, row) }
273 }
274 return nil
275 }
276
277 private func overlapAt(point: NSPoint) -> ClipOverlap? {
278 guard let row = rowAt(y: point.y), let ref = laneRef(row: row) else { return nil }
279 rebuildSceneIfNeeded()
280 for o in overlapsByLane[ref] ?? [] {
281 let lane = laneRect(row: row)
282 let r = NSRect(x: xFor(o.start), y: lane.minY,
283 width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height)
284 if r.contains(point) { return o }
285 }
286 return nil
287 }
288
289 // MARK: - Drawing
290
291 private var panelNamesCache: [UUID: String] = [:]
292 private var linkedSelectionCache: Set<UUID> = []
293 // Per-lane clips (sorted by start) and overlap ranges, grouped ONCE per
294 // model change rather than re-filtered/re-sorted for every lane on every
295 // frame. Rebuilt lazily when `sceneDirty` is set — which `redraw()` does on
296 // any model/selection/media/view/comps change. Scrolling, zooming and the
297 // 60 Hz playhead only set `needsDisplay` (not `sceneDirty`), so a pan over a
298 // large project is now a redraw of cached geometry, not a full recompute.
299 private var clipsByLane: [TrackRef: [Clip]] = [:]
300 private var overlapsByLane: [TrackRef: [ClipOverlap]] = [:]
301 /// Ids of clips that are part of an overlap, per lane — hoisted out of
302 /// `drawLane`, which used to rebuild this Set per lane per frame.
303 private var overlapIdsByLane: [TrackRef: Set<UUID>] = [:]
304 /// Longest clip duration per lane — bounds the binary-searched draw/culling
305 /// window (a clip can start at most this far left of the view and still be
306 /// visible).
307 private var maxClipDurByLane: [TrackRef: Double] = [:]
308 /// `project.timelineDuration` scans every clip; cached here so per-frame
309 /// chrome (scrollbars, origin clamping) doesn't rescan 4k+ clips.
310 private var cachedTimelineDuration: Double = 0
311 private var mediaById: [UUID: MediaItem] = [:]
312 private var sceneDirty = true
313
314 /// Clip-title text attributes — hoisted out of the per-clip loop so we
315 /// don't rebuild the dictionary (and re-resolve the font) for every clip on
316 /// every frame.
317 private static let titleAttrs: [NSAttributedString.Key: Any] = [
318 .font: NSFont.systemFont(ofSize: 9.5, weight: .medium),
319 .foregroundColor: NSColor(calibratedWhite: 0.92, alpha: 1),
320 ]
321
322 /// Clip titles as cached CTLines. `NSString.draw` runs a full CoreText
323 /// layout per call — ~200 visible labels re-laid-out every cold frame,
324 /// even though a multicam project has a handful of distinct names. Layout
325 /// once per distinct title, then `CTLineDraw` is a glyph blit. Capped:
326 /// titles are media names + panel names, a small closed set in practice.
327 private static var titleLineCache: [String: (line: CTLine, ascent: CGFloat)] = [:]
328 private static let titleLineLock = NSLock() // lanes rasterize in parallel
329 private static func titleLine(_ s: String) -> (line: CTLine, ascent: CGFloat) {
330 titleLineLock.lock()
331 defer { titleLineLock.unlock() }
332 if let c = titleLineCache[s] { return c }
333 if titleLineCache.count > 512 { titleLineCache.removeAll(keepingCapacity: true) }
334 let line = CTLineCreateWithAttributedString(
335 NSAttributedString(string: s, attributes: titleAttrs))
336 var ascent: CGFloat = 0
337 CTLineGetTypographicBounds(line, &ascent, nil, nil)
338 let entry = (line, ascent)
339 titleLineCache[s] = entry
340 return entry
341 }
342
343 /// Draw a cached title line with its top-left at `point` in the flipped
344 /// view space — positioned to match what `NSString.draw(at:)` produced.
345 private func drawTitle(_ s: String, at point: NSPoint) {
346 guard let cg = NSGraphicsContext.current?.cgContext else { return }
347 let (line, ascent) = Self.titleLine(s)
348 cg.saveGState()
349 // Flipped context: un-flip locally for glyph drawing. The box-top to
350 // baseline offset is the line's ascent, matching NSString.draw(at:).
351 cg.translateBy(x: point.x, y: point.y + ascent)
352 cg.scaleBy(x: 1, y: -1)
353 cg.textMatrix = .identity
354 cg.textPosition = .zero
355 CTLineDraw(line, cg)
356 cg.restoreGState()
357 }
358
359 /// SF Symbols, tinted and baked into flat bitmaps, cached by `key`. Building
360 /// an SF Symbol and tinting it (`tinted` does a `lockFocus` composite) *per
361 /// draw* was the single biggest timeline draw cost: nearly every clip is
362 /// linked, so the link/mute badges alone ran hundreds of times a frame, and
363 /// the track-header buttons re-baked every frame too. Cached, drawing a badge
364 /// is a plain blit. Main-thread only (all drawing is).
365 private static var symbolCache: [String: NSImage] = [:]
366 private static let symbolCacheLock = NSLock() // lanes rasterize in parallel
367 static func bakedSymbol(_ name: String, pointSize: CGFloat = 0,
368 weight: NSFont.Weight = .regular,
369 tint: NSColor, key: String) -> NSImage? {
370 symbolCacheLock.lock()
371 defer { symbolCacheLock.unlock() }
372 if let img = symbolCache[key] { return img }
373 var base = NSImage(systemSymbolName: name, accessibilityDescription: name)
374 if pointSize > 0 {
375 base = base?.withSymbolConfiguration(.init(pointSize: pointSize, weight: weight))
376 }
377 guard let tinted = base?.tinted(tint) else { return nil }
378 // Bake into a 2× bitmap so the flattened badge stays crisp on Retina
379 // (a plain `lockFocus` would capture at whatever scale is current).
380 let size = tinted.size
381 guard size.width > 0, size.height > 0,
382 let rep = NSBitmapImageRep(
383 bitmapDataPlanes: nil, pixelsWide: Int(size.width * 2),
384 pixelsHigh: Int(size.height * 2), bitsPerSample: 8, samplesPerPixel: 4,
385 hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB,
386 bytesPerRow: 0, bitsPerPixel: 0) else { return nil }
387 rep.size = size
388 NSGraphicsContext.saveGraphicsState()
389 NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
390 tinted.draw(in: NSRect(origin: .zero, size: size))
391 NSGraphicsContext.restoreGraphicsState()
392 let baked = NSImage(size: size)
393 baked.addRepresentation(rep)
394 symbolCache[key] = baked
395 return baked
396 }
397 private static var mutedBadge: NSImage? {
398 bakedSymbol("speaker.slash.fill", tint: .white, key: "badge.muted")
399 }
400 private static var linkBadge: NSImage? {
401 bakedSymbol("link", tint: .white, key: "badge.link")
402 }
403
404 /// Index range of a lane's (start-sorted) clips that can intersect the
405 /// time window — binary-searched so a frame's work is proportional to
406 /// what's on screen, not to how far into a 10-hour project it sits. The
407 /// lower bound backs off by the lane's longest clip (any clip starting
408 /// earlier than that has necessarily ended before `left`); the upper bound
409 /// is the first start past `right`. Callers still skip the odd short clip
410 /// inside the backoff span with an `end < left` check.
411 private func visibleIndexRange(_ clips: [Clip], lane: TrackRef,
412 left: Double, right: Double) -> Range<Int> {
413 let backoff = left - (maxClipDurByLane[lane] ?? 0)
414 var lo = 0, hi = clips.count
415 while lo < hi { // first index with start >= backoff
416 let mid = (lo + hi) / 2
417 if clips[mid].start < backoff { lo = mid + 1 } else { hi = mid }
418 }
419 let first = lo
420 hi = clips.count
421 while lo < hi { // first index with start > right
422 let mid = (lo + hi) / 2
423 if clips[mid].start <= right { lo = mid + 1 } else { hi = mid }
424 }
425 return first..<lo
426 }
427
428 private func rebuildSceneIfNeeded() {
429 guard sceneDirty else { return }
430 panelNamesCache = project.panelNames()
431 // Link-mates of the selection get an aqua outline (they act selected).
432 linkedSelectionCache = project.expandLinks(store.selection)
433 .subtracting(store.selection)
434 clipsByLane = Dictionary(grouping: project.clips, by: \.track)
435 for ref in clipsByLane.keys {
436 clipsByLane[ref]?.sort { $0.start < $1.start }
437 }
438 // `overlaps()` (no arg) computes every lane in one pass; grouping by
439 // track yields the same per-lane arrays `overlaps(on:)` returned.
440 overlapsByLane = Dictionary(grouping: project.overlaps(), by: \.track)
441 overlapIdsByLane = overlapsByLane.mapValues {
442 Set($0.flatMap { [$0.a.id, $0.b.id] })
443 }
444 maxClipDurByLane = clipsByLane.mapValues {
445 $0.map(\.duration).max() ?? 0
446 }
447 cachedTimelineDuration = project.timelineDuration
448 mediaById = Dictionary(project.media.map { ($0.id, $0) },
449 uniquingKeysWith: { a, _ in a })
450 // Per-lane colours: the track colour and its black-blended body/strip
451 // shades depend only on the lane's hue, but were re-derived (an NSColor
452 // alloc + two colour-space `blended()` conversions) for every clip every
453 // frame. Compute them once per lane here.
454 laneColorCache = [:]
455 for ref in laneRows {
456 let base = trackColor(ref)
457 laneColorCache[ref] = LaneColors(
458 base: base,
459 videoBody: base.blended(withFraction: 0.75, of: .black) ?? base,
460 audioBody: base.blended(withFraction: 0.82, of: .black) ?? base,
461 strip: (base.blended(withFraction: 0.5, of: .black) ?? base)
462 .withAlphaComponent(0.85))
463 }
464 sceneDirty = false
465 }
466
467 private struct LaneColors {
468 let base: NSColor // borders, waveform, filmstrip tint
469 let videoBody: NSColor // video clip body fill
470 let audioBody: NSColor // audio clip body fill
471 let strip: NSColor // title-strip fill
472 }
473 private var laneColorCache: [TrackRef: LaneColors] = [:]
474 private func laneColors(_ ref: TrackRef) -> LaneColors {
475 if let c = laneColorCache[ref] { return c }
476 let base = trackColor(ref)
477 return LaneColors(base: base,
478 videoBody: base.blended(withFraction: 0.75, of: .black) ?? base,
479 audioBody: base.blended(withFraction: 0.82, of: .black) ?? base,
480 strip: (base.blended(withFraction: 0.5, of: .black) ?? base)
481 .withAlphaComponent(0.85))
482 }
483
484 /// Clips intersecting the visible time window, across all lanes. Cheap: the
485 /// per-lane arrays are sorted, so it culls the same way the draw loop does.
486 private func visibleClipCount() -> Int {
487 let left = originSecond
488 let right = originSecond + Double(bounds.width - headerW) / pxPerSecond
489 var n = 0
490 for ref in laneRows {
491 let clips = clipsByLane[ref] ?? []
492 for i in visibleIndexRange(clips, lane: ref, left: left, right: right)
493 where clips[i].end >= left {
494 n += 1
495 }
496 }
497 return n
498 }
499
500 /// Below this on-screen width a clip physically cannot show its corner
501 /// radius, title strip text, badges, or a meaningful filmstrip/waveform
502 /// column — so `drawClip` takes a flat-rect fast path that produces the
503 /// same pixels for a fraction of the cost. Detail is a function of what's
504 /// resolvable at the current zoom, never of whether the user is scrolling:
505 /// this replaces the old `lightScroll` mode, which shed thumbnails and
506 /// labels mid-pan (visibly) yet saved almost nothing — the real per-clip
507 /// cost was the path/clip-state chrome that ran for sub-pixel clips.
508 private static let lodMinWidth: CGFloat = 3
509
510 // MARK: - Scene tile cache
511 //
512 // The scene (lane clips: bodies, filmstrips, waveforms, strips, labels,
513 // badges, selection tint) is rasterized into per-lane, 512-pt-wide tiles in
514 // TIMELINE space — a tile's x axis is (t − sliceStart)·pxPerSecond, which
515 // is independent of the pan origin. Panning and the 60 Hz playhead redraw
516 // therefore cost a handful of blits plus the (cheap) chrome, instead of
517 // re-rendering thousands of clips. Anything that changes scene pixels —
518 // model edits, selection, thumbnails landing, theme, zoom, lane heights —
519 // flushes; tiles rebuild lazily, visible-first, at direct-render cost.
520 // Active edit gestures bypass tiles entirely (direct draw), so a drag never
521 // thrashes the cache mid-gesture.
522
523 private struct TileKey: Hashable {
524 let ref: TrackRef
525 let slice: Int
526 }
527 /// Everything a tile's pixels depend on besides the model/selection (those
528 /// flush via notifications). A mismatch flushes the whole cache.
529 private struct TileParams: Equatable {
530 var pps: Double
531 var scale: CGFloat
532 var light: Bool
533 var heights: [TrackRef: CGFloat]
534 var showFilmstrips: Bool
535 }
536 private var tiles: [TileKey: CGImage] = [:]
537 private var tileUse: [TileKey: Int] = [:]
538 private var tileTick = 0
539 private var tileParams: TileParams?
540 private static let tileW: CGFloat = 512
541 /// ~96 tiles ≈ a few viewports of 2× lane strips (~100 MB worst case).
542 private static let maxTiles = 96
543 /// Cap on tile rasterizations per frame. A cold viewport (fresh zoom, big
544 /// jump) draws the un-cached slices directly this frame — same pixels,
545 /// direct cost — and fills the cache over the next few frames instead of
546 /// paying ~25 bitmap allocations in one frame.
547 private static let tileRendersPerFrame = 6
548 private var tileRenderBudget = 0
549 private var tileFillPending = false
550 private var lastDrawOrigin = 0.0
551 private var lastDrawPps = 0.0
552 /// Escape hatch for A/B measurement and debugging: SEQ_NOTILES=1 forces
553 /// the direct (Stage-1) render path. Mutable so --perftest can A/B both
554 /// paths inside one process (immune to thermal drift between runs).
555 static var tilesDisabled =
556 ProcessInfo.processInfo.environment["SEQ_NOTILES"] == "1"
557
558 private func flushTiles() {
559 tiles.removeAll(keepingCapacity: true)
560 tileUse.removeAll(keepingCapacity: true)
561 }
562
563 /// Device-pixel scale of the surface actually being drawn into, captured
564 /// from the context CTM at the top of `draw`. Falling back to the window's
565 /// backing scale is only right when they agree — an offscreen 1× target
566 /// (like --perftest's bitmap) on a 2× machine would otherwise get 2× tiles
567 /// downsampled on every blit.
568 private var renderScale: CGFloat = 2
569
570 private var backingScale: CGFloat {
571 window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
572 }
573
574 /// Quantize a length/offset to the device-pixel grid. Tiles are blitted at
575 /// integral device pixels; quantizing the shared geometry (origin, scroll,
576 /// lane heights) keeps the tile and direct paths on the same sub-pixel
577 /// phase, so their output is pixel-identical and blits never resample.
578 private func quantized(_ v: CGFloat) -> CGFloat {
579 (v * renderScale).rounded() / renderScale
580 }
581
582 private func evictTilesIfNeeded() {
583 guard tiles.count > Self.maxTiles else { return }
584 for (key, _) in tileUse.sorted(by: { $0.value < $1.value })
585 .prefix(tiles.count - Self.maxTiles) {
586 tiles[key] = nil
587 tileUse[key] = nil
588 }
589 }
590
591 /// Blit the lane's visible tiles. Missing tiles are rendered up to the
592 /// per-frame budget; past it, their span draws directly (identical pixels)
593 /// and a follow-up display pass finishes filling the cache.
594 private func blitLaneTiles(ref: TrackRef, lane: NSRect) {
595 guard let cg = NSGraphicsContext.current?.cgContext else { return }
596 let sliceDur = Double(Self.tileW) / pxPerSecond
597 let leftSec = secondsFor(headerW)
598 let rightSec = secondsFor(bounds.width)
599 var slice = Int(floor(leftSec / sliceDur))
600 let last = Int(floor(rightSec / sliceDur))
601 while slice <= last {
602 let x = xFor(Double(slice) * sliceDur)
603 if let img = tileImage(ref: ref, lane: lane, slice: slice, sliceDur: sliceDur) {
604 // CGContext blit (NSImage.draw pays rep-matching/colorspace
605 // overhead per call). Local flip: CGImages draw bottom-up.
606 cg.saveGState()
607 cg.translateBy(x: x, y: lane.maxY)
608 cg.scaleBy(x: 1, y: -1)
609 cg.draw(img, in: CGRect(x: 0, y: 0, width: Self.tileW,
610 height: lane.height))
611 cg.restoreGState()
612 if DrawProf.on { DrawProf.tileBlits += 1 }
613 } else {
614 // Over budget this frame: draw the slice's span directly.
615 NSGraphicsContext.current?.saveGraphicsState()
616 NSRect(x: x, y: lane.minY, width: Self.tileW,
617 height: lane.height).clip()
618 drawLaneClips(ref: ref, lane: lane, cullX0: x, cullX1: x + Self.tileW)
619 NSGraphicsContext.current?.restoreGraphicsState()
620 scheduleTileFill()
621 }
622 slice += 1
623 }
624 }
625
626 /// One coalesced follow-up display pass to keep rasterizing missed tiles
627 /// after a budget-limited frame.
628 private func scheduleTileFill() {
629 guard !tileFillPending else { return }
630 tileFillPending = true
631 DispatchQueue.main.async { [weak self] in
632 guard let self else { return }
633 self.tileFillPending = false
634 self.needsDisplay = true
635 }
636 }
637
638 /// The cached tile for (lane, slice), rendering it via the SAME
639 /// `drawLaneClips` code the direct path uses — pixel-equivalent by
640 /// construction. The context is translated so the slice's start lands at
641 /// x = 0 and the lane's top at y = 0; both offsets cancel the pan origin,
642 /// which is what makes the raster reusable across scroll positions.
643 private func tileImage(ref: TrackRef, lane: NSRect, slice: Int,
644 sliceDur: Double) -> CGImage? {
645 let key = TileKey(ref: ref, slice: slice)
646 tileTick += 1
647 tileUse[key] = tileTick
648 if let img = tiles[key] { return img }
649 guard tileRenderBudget > 0 else { return nil }
650 tileRenderBudget -= 1
651
652 let scale = renderScale
653 let pxW = Int((Self.tileW * scale).rounded())
654 let pxH = Int((lane.height * scale).rounded())
655 // Native Quartz raster format (BGRA premultiplied, little-endian) in
656 // the window's own colorspace: blits are then straight memory copies.
657 // An NSBitmapImageRep here (RGBA, deviceRGB) costs a per-blit swizzle
658 // + colorspace conversion — ~1.5 ms per tile, wiping out the caching.
659 let space = window?.colorSpace?.cgColorSpace
660 ?? CGColorSpace(name: CGColorSpace.sRGB)!
661 guard pxW > 0, pxH > 0,
662 let cg = CGContext(
663 data: nil, width: pxW, height: pxH, bitsPerComponent: 8,
664 bytesPerRow: 0, space: space,
665 bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
666 | CGBitmapInfo.byteOrder32Little.rawValue) else { return nil }
667
668 NSGraphicsContext.saveGraphicsState()
669 // Map: device pixels → points, unflipped → flipped (the view draws
670 // top-down), view space → tile space. Wrapped in a `flipped: true`
671 // NSGraphicsContext so text and images orient correctly.
672 cg.scaleBy(x: scale, y: scale)
673 cg.translateBy(x: 0, y: lane.height)
674 cg.scaleBy(x: 1, y: -1)
675 let x0 = xFor(Double(slice) * sliceDur)
676 cg.translateBy(x: -x0, y: -lane.minY)
677 NSGraphicsContext.current = NSGraphicsContext(cgContext: cg, flipped: true)
678 drawLaneClips(ref: ref, lane: lane, cullX0: x0, cullX1: x0 + Self.tileW)
679 NSGraphicsContext.restoreGraphicsState()
680
681 guard let img = cg.makeImage() else { return nil }
682 tiles[key] = img
683 evictTilesIfNeeded()
684 if DrawProf.on { DrawProf.tileRenders += 1 }
685 return img
686 }
687
688 /// Live draw-time telemetry, on when the app is launched with
689 /// SEQ_DRAWPROF=1 in the environment (run the binary directly with stderr
690 /// to a file — NSLog is invisible under `open`). Prints one line per
691 /// second: draws that second and mean/max ms per draw. Purely additive so
692 /// GUI perf work can be judged against real numbers, like --perftest.
693 private static let liveProf = ProcessInfo.processInfo.environment["SEQ_DRAWPROF"] == "1"
694 private var profWindowStart = CACurrentMediaTime()
695 private var profFrames = 0
696 private var profTotalMs = 0.0
697 private var profMaxMs = 0.0
698
699 override func draw(_ dirtyRect: NSRect) {
700 let profT0 = Self.liveProf ? CACurrentMediaTime() : 0
701 defer {
702 if Self.liveProf {
703 let ms = (CACurrentMediaTime() - profT0) * 1000
704 profFrames += 1
705 profTotalMs += ms
706 profMaxMs = max(profMaxMs, ms)
707 let now = CACurrentMediaTime()
708 if now - profWindowStart >= 1.0 {
709 FileHandle.standardError.write(Data(String(format:
710 "[drawprof] %d draws, avg %.2f ms, max %.2f ms\n",
711 profFrames, profTotalMs / Double(profFrames),
712 profMaxMs).utf8))
713 profWindowStart = now
714 profFrames = 0
715 profTotalMs = 0
716 profMaxMs = 0
717 }
718 }
719 }
720 Theme.timelineBg.setFill()
721 bounds.fill()
722
723 // Publish the on-screen time span — a heat anchor for the proxy
724 // builder (the user scrubs inside what they can see). Cheap enough
725 // to set every frame; read lazily by the chunk scheduler.
726 let visibleSpan = Double(bounds.width - headerW) / pxPerSecond
727 ctx.session.visibleTimeRange = originSecond...(originSecond + max(1, visibleSpan))
728
729 DrawProf.t("scene") { rebuildSceneIfNeeded() }
730 DrawProf.t("cull") {
731 // Match the tile raster scale to the surface being drawn into.
732 if let ctm = NSGraphicsContext.current?.cgContext
733 .userSpaceToDeviceSpaceTransform {
734 let s = abs(ctm.a)
735 if s > 0.1, s != renderScale {
736 renderScale = s
737 flushTiles()
738 }
739 }
740 scrollY = quantized(min(scrollY, maxScrollY))
741 // Snap the pan origin to the device-pixel grid (≤ half a device
742 // pixel, imperceptible) so tile blits land on integral pixels and
743 // the tile and direct paths share one sub-pixel phase.
744 let q = pxPerSecond * Double(renderScale)
745 originSecond = (originSecond * q).rounded() / q
746 }
747 // Route the frame: warm frames (rest, pan, playback) blit the tile
748 // cache; cold frames — a zoom in flight (every tile invalid), a
749 // teleport jump (a whole viewport of new slices), or a live edit
750 // gesture (model changes per event) — rasterize all lanes in parallel
751 // instead, and the cache refills once things settle.
752 let editing = store.gestureBaseModel != nil || drag.mode == .box
753 let jumped = abs(originSecond - lastDrawOrigin) * pxPerSecond
754 > Double(Self.tileW)
755 let zoomed = pxPerSecond != lastDrawPps
756 lastDrawOrigin = originSecond
757 lastDrawPps = pxPerSecond
758 let tiled = !Self.tilesDisabled && !editing && !zoomed && !jumped
759 tileRenderBudget = Self.tileRendersPerFrame
760 if tiled {
761 let params = TileParams(
762 pps: pxPerSecond, scale: renderScale, light: Theme.light,
763 heights: Dictionary(uniqueKeysWithValues:
764 laneRows.map { ($0, laneHeight($0)) }),
765 showFilmstrips: session.showFilmstrips)
766 if params != tileParams {
767 flushTiles()
768 tileParams = params
769 }
770 } else if zoomed || jumped {
771 // The gesture invalidated the cache; warm it back up as soon as
772 // the stream of cold frames stops.
773 flushTiles()
774 tileParams = nil
775 scheduleTileFill()
776 }
777 let rows = laneRows
778 DrawProf.t("lanes") {
779 if tiled {
780 for row in 0..<rows.count {
781 drawLane(row: row, ref: rows[row], tiled: true)
782 }
783 } else {
784 drawLanesDirectParallel(rows: rows)
785 }
786 }
787 DrawProf.t("hints") {
788 drawDragHintLanes()
789 drawFileDropPreview()
790 }
791 DrawProf.t("fusion") { drawFusionBand() }
792 // Opaque header column so clips never show behind the buttons.
793 Theme.timelineBg.setFill()
794 NSRect(x: 0, y: rulerH, width: headerW, height: bounds.height - rulerH).fill()
795 DrawProf.t("headers") {
796 drawFusionHeader()
797 for row in 0..<rows.count {
798 drawTrackHeader(row: row, ref: rows[row], lane: laneRect(row: row))
799 }
800 }
801 DrawProf.t("ruler") { drawRuler() }
802 DrawProf.t("strip") { drawOptimizeStrip() }
803 DrawProf.t("overlays") {
804 drawInOut()
805 drawMarkers()
806 drawSnapIndicator()
807 drawBoxSelect()
808 drawPlayhead()
809 }
810 DrawProf.t("scrollbars") { drawScrollbars() }
811 }
812
813 // MARK: - Scroll/zoom bars
814 // Floating bars: drag the middle to pan; drag either END to zoom —
815 // resizing the thumb literally resizes the viewport. Horizontal = time
816 // zoom, vertical = track height.
817
818 private let sbThick: CGFloat = 9
819
820 private var hBarRect: NSRect {
821 NSRect(x: headerW + 2, y: bounds.height - sbThick - 3,
822 width: max(10, bounds.width - headerW - sbThick - 10), height: sbThick)
823 }
824 private var vBarRect: NSRect {
825 NSRect(x: bounds.width - sbThick - 3, y: lanesTop + 2,
826 width: sbThick, height: max(10, bounds.height - lanesTop - sbThick - 10))
827 }
828
829 private func hDomain() -> (lo: Double, hi: Double) {
830 let viewSec = Double(bounds.width - headerW) / pxPerSecond
831 let lo = min(0, originSecond)
832 rebuildSceneIfNeeded()
833 let hi = max(cachedTimelineDuration + 10, originSecond + viewSec)
834 return (lo, hi)
835 }
836
837 private func hThumbRect() -> NSRect {
838 let bar = hBarRect
839 let (lo, hi) = hDomain()
840 let viewSec = Double(bounds.width - headerW) / pxPerSecond
841 let span = max(0.001, hi - lo)
842 let x0 = bar.minX + CGFloat((originSecond - lo) / span) * bar.width
843 let w = min(bar.width, max(28, CGFloat(viewSec / span) * bar.width))
844 return NSRect(x: min(max(bar.minX, x0), bar.maxX - w), y: bar.minY,
845 width: w, height: bar.height)
846 }
847
848 private func vThumbRect() -> NSRect {
849 let bar = vBarRect
850 let contentH = max(lanesContentHeight, 1)
851 let viewH = max(1, bounds.height - lanesTop)
852 let f = min(1, viewH / contentH)
853 let y0 = bar.minY + (scrollY / contentH) * bar.height
854 let h = min(bar.height, max(24, f * bar.height))
855 return NSRect(x: bar.minX, y: min(max(bar.minY, y0), bar.maxY - h),
856 width: bar.width, height: h)
857 }
858
859 private func drawScrollbars() {
860 for (bar, thumb) in [(hBarRect, hThumbRect()), (vBarRect, vThumbRect())] {
861 guard bar.width > 20, bar.height > 4 else { continue }
862 Theme.label.withAlphaComponent(0.06).setFill()
863 NSBezierPath(roundedRect: bar, xRadius: sbThick / 2, yRadius: sbThick / 2).fill()
864 Theme.label.withAlphaComponent(0.25).setFill()
865 NSBezierPath(roundedRect: thumb, xRadius: sbThick / 2, yRadius: sbThick / 2).fill()
866 // End grips (the zoom handles)
867 Theme.label.withAlphaComponent(0.55).setFill()
868 if bar.width > bar.height {
869 NSBezierPath(ovalIn: NSRect(x: thumb.minX + 2.5, y: thumb.midY - 2,
870 width: 4, height: 4)).fill()
871 NSBezierPath(ovalIn: NSRect(x: thumb.maxX - 6.5, y: thumb.midY - 2,
872 width: 4, height: 4)).fill()
873 } else {
874 NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.minY + 2.5,
875 width: 4, height: 4)).fill()
876 NSBezierPath(ovalIn: NSRect(x: thumb.midX - 2, y: thumb.maxY - 6.5,
877 width: 4, height: 4)).fill()
878 }
879 }
880 }
881
882 private func scrollbarHit(_ p: NSPoint) -> DragMode? {
883 let hT = hThumbRect(), vT = vThumbRect()
884 if hBarRect.insetBy(dx: 0, dy: -3).contains(p) {
885 if abs(p.x - hT.minX) < 8 { return .hBarLeft }
886 if abs(p.x - hT.maxX) < 8 { return .hBarRight }
887 return .hBarPan
888 }
889 if vBarRect.insetBy(dx: -3, dy: 0).contains(p) {
890 if abs(p.y - vT.minY) < 8 { return .vBarTop }
891 if abs(p.y - vT.maxY) < 8 { return .vBarBottom }
892 return .vBarPan
893 }
894 return nil
895 }
896
897 private func drawLane(row: Int, ref: TrackRef, tiled: Bool) {
898 let lane = laneRect(row: row)
899 guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { return }
900 (ref == .storyboard ? Theme.storyboardLaneBg : Theme.laneBg).setFill()
901 NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill()
902
903 if tiled {
904 blitLaneTiles(ref: ref, lane: lane)
905 } else {
906 drawLaneClips(ref: ref, lane: lane,
907 cullX0: headerW, cullX1: bounds.width)
908 }
909 drawLaneDecorations(ref: ref, lane: lane)
910 }
911
912 /// Pinned labels + overlap bands — main-thread, over blits or direct clips.
913 private func drawLaneDecorations(ref: TrackRef, lane: NSRect) {
914 drawPinnedLabels(ref: ref, lane: lane)
915
916 // Bright red overlap ranges on top of the clip bodies.
917 for o in overlapsByLane[ref] ?? [] {
918 let r = NSRect(x: xFor(o.start), y: lane.minY + 1,
919 width: max(2, xFor(o.end) - xFor(o.start)), height: lane.height - 2)
920 guard r.maxX > headerW, r.minX < bounds.width else { continue }
921 NSColor.systemRed.withAlphaComponent(0.40).setFill()
922 r.fill()
923 NSColor.systemRed.setStroke()
924 let p = NSBezierPath(rect: r.insetBy(dx: 0.5, dy: 0.5))
925 p.lineWidth = 1.5
926 p.stroke()
927 }
928 }
929
930 // MARK: - Parallel direct rendering (cold frames)
931
932 /// Persistent per-lane raster buffers for cold frames; recreated on size/
933 /// scale change. ~7 MB per lane at 2× on a wide window — the price of
934 /// rasterizing a zoom gesture on every core instead of one.
935 private var laneBufs: [TrackRef: CGContext] = [:]
936
937 /// A cold frame (zoom in flight, teleport jump, live edit gesture) must
938 /// re-render every visible lane from scratch — but lanes are independent,
939 /// so rasterize them CONCURRENTLY into per-lane buffers with the exact
940 /// same `drawLaneClips` code and composite on main. Same rasterizer, same
941 /// pixels; wall-clock is the slowest lane, not the sum. The storyboard
942 /// lane draws serially on main (BoardStore's raster cache isn't locked).
943 private func drawLanesDirectParallel(rows: [TrackRef]) {
944 var work: [(ref: TrackRef, lane: NSRect)] = []
945 for (row, ref) in rows.enumerated() {
946 let lane = laneRect(row: row)
947 guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { continue }
948 (ref == .storyboard ? Theme.storyboardLaneBg : Theme.laneBg).setFill()
949 NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4).fill()
950 work.append((ref, lane))
951 }
952 guard !work.isEmpty, let mainCG = NSGraphicsContext.current?.cgContext
953 else { return }
954
955 let scale = renderScale
956 let pxW = Int((bounds.width * scale).rounded())
957 let space = window?.colorSpace?.cgColorSpace
958 ?? CGColorSpace(name: CGColorSpace.sRGB)!
959 let ctxs: [CGContext?] = work.map { item in
960 if item.ref == .storyboard { return nil }
961 let pxH = Int((item.lane.height * scale).rounded())
962 if let c = laneBufs[item.ref], c.width == pxW, c.height == pxH {
963 return c
964 }
965 let c = CGContext(
966 data: nil, width: pxW, height: pxH, bitsPerComponent: 8,
967 bytesPerRow: 0, space: space,
968 bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
969 | CGBitmapInfo.byteOrder32Little.rawValue)
970 laneBufs[item.ref] = c
971 return c
972 }
973
974 DispatchQueue.concurrentPerform(iterations: work.count) { i in
975 guard let cg = ctxs[i] else { return } // storyboard → main below
976 let (ref, lane) = work[i]
977 autoreleasepool {
978 cg.clear(CGRect(x: 0, y: 0, width: CGFloat(cg.width),
979 height: CGFloat(cg.height)))
980 cg.saveGState()
981 cg.scaleBy(x: scale, y: scale)
982 cg.translateBy(x: 0, y: lane.height)
983 cg.scaleBy(x: 1, y: -1)
984 cg.translateBy(x: 0, y: -lane.minY)
985 // NSGraphicsContext.current is thread-local — but
986 // concurrentPerform runs one of these iterations ON the
987 // calling (main) thread, so the view's own context must be
988 // restored, not nilled.
989 let prev = NSGraphicsContext.current
990 NSGraphicsContext.current = NSGraphicsContext(cgContext: cg, flipped: true)
991 drawLaneClips(ref: ref, lane: lane,
992 cullX0: headerW, cullX1: bounds.width)
993 NSGraphicsContext.current = prev
994 cg.restoreGState()
995 }
996 }
997
998 for (i, item) in work.enumerated() {
999 if let cg = ctxs[i] {
1000 if let img = cg.makeImage() {
1001 mainCG.saveGState()
1002 mainCG.translateBy(x: 0, y: item.lane.maxY)
1003 mainCG.scaleBy(x: 1, y: -1)
1004 mainCG.draw(img, in: CGRect(x: 0, y: 0, width: bounds.width,
1005 height: item.lane.height))
1006 mainCG.restoreGState()
1007 }
1008 } else {
1009 drawLaneClips(ref: item.ref, lane: item.lane,
1010 cullX0: headerW, cullX1: bounds.width)
1011 }
1012 drawLaneDecorations(ref: item.ref, lane: item.lane)
1013 }
1014 }
1015
1016 /// One lane's clips into the current context, culled and clamped to the
1017 /// window [cullX0, cullX1] (view coordinates). The window is the view for
1018 /// direct drawing and the tile's span (± bleed) when rendering a tile —
1019 /// the drawing itself is identical, so the two paths are pixel-equivalent.
1020 private func drawLaneClips(ref: TrackRef, lane: NSRect,
1021 cullX0: CGFloat, cullX1: CGFloat) {
1022 let overlappingIds = overlapIdsByLane[ref] ?? []
1023 // Bleed: fade handles / shot dividers / borders paint a few points
1024 // past a clip's rect, so a clip just outside the window still owns
1025 // pixels inside it.
1026 let bleed: CGFloat = 8
1027 let leftSec = secondsFor(cullX0 - bleed)
1028 let rightSec = secondsFor(cullX1 + bleed)
1029 let colors = laneColors(ref)
1030 let clips = clipsByLane[ref] ?? []
1031 // A storyboard panel's left divider depends on whether an earlier panel
1032 // exists on the lane; the array is start-sorted, so any predecessor
1033 // with a strictly smaller start means "has previous".
1034 let range = visibleIndexRange(clips, lane: ref, left: leftSec, right: rightSec)
1035 for i in range {
1036 let clip = clips[i]
1037 if clip.end < leftSec { continue }
1038 let hasPrevPanel = ref == .storyboard && i > 0
1039 && clips[0].start < clip.start - 1e-6
1040 drawClip(clip, lane: lane, ref: ref, colors: colors,
1041 overlapping: overlappingIds.contains(clip.id),
1042 hasPrevPanel: hasPrevPanel,
1043 cullX0: cullX0 - bleed, cullX1: cullX1 + bleed)
1044 }
1045 }
1046
1047 /// Circular SF-Symbol button in the header column (hide preview / focus).
1048 private func drawHeaderButton(_ symbolName: String, centerY: CGFloat, on: Bool) {
1049 drawHeaderButton(symbolName, in: NSRect(x: headerW / 2 - 8.5, y: centerY - 8.5,
1050 width: 17, height: 17), on: on)
1051 }
1052
1053 private func drawHeaderButton(_ symbolName: String, in r: NSRect, on: Bool) {
1054 (on ? NSColor.white : NSColor.black.withAlphaComponent(0.35)).setFill()
1055 NSBezierPath(ovalIn: r).fill()
1056 let tint = on ? NSColor.black : NSColor(calibratedWhite: 0.95, alpha: 0.9)
1057 guard let img = Self.bakedSymbol(symbolName, pointSize: 9, weight: .bold,
1058 tint: tint, key: "hdr.\(symbolName).\(on)") else { return }
1059 let s = img.size
1060 img.draw(in: NSRect(x: r.midX - s.width / 2, y: r.midY - s.height / 2,
1061 width: s.width, height: s.height),
1062 from: .zero, operation: .sourceOver, fraction: 1)
1063 }
1064
1065 /// A vivid swatch of the track's own colour — opens the picker on click.
1066 private func drawHeaderColorSwatch(in r: NSRect, hue: Double) {
1067 NSColor(calibratedHue: hue, saturation: 0.85, brightness: 1, alpha: 1).setFill()
1068 NSBezierPath(ovalIn: r).fill()
1069 NSColor.white.withAlphaComponent(0.95).setStroke()
1070 let ring = NSBezierPath(ovalIn: r.insetBy(dx: 0.75, dy: 0.75))
1071 ring.lineWidth = 1.5
1072 ring.stroke()
1073 NSColor.black.withAlphaComponent(0.5).setStroke()
1074 let outer = NSBezierPath(ovalIn: r.insetBy(dx: -0.25, dy: -0.25))
1075 outer.lineWidth = 0.75
1076 outer.stroke()
1077 }
1078
1079 /// The hide / focus (and, on tall enough lanes, colour) button rects for a
1080 /// header lane. One source of truth for drawing AND hit-testing. `color` is
1081 /// nil when the lane is too short to also fit the swatch.
1082 func headerButtonRects(lane: NSRect) -> (hide: NSRect, focus: NSRect, color: NSRect?) {
1083 func rect(_ cy: CGFloat, _ d: CGFloat = 17) -> NSRect {
1084 NSRect(x: headerW / 2 - d / 2, y: cy - d / 2, width: d, height: d)
1085 }
1086 if lane.height >= Self.headerColorMinHeight {
1087 return (rect(lane.minY + lane.height * 0.22),
1088 rect(lane.minY + lane.height * 0.50),
1089 rect(lane.minY + lane.height * 0.78, 15))
1090 }
1091 return (rect(lane.minY + lane.height * 0.28),
1092 rect(lane.minY + lane.height * 0.72), nil)
1093 }
1094
1095 private func drawTrackHeader(row: Int, ref: TrackRef, lane: NSRect) {
1096 // Header column: track color strip with hide (eye.slash), focus
1097 // (expand), and — when there's room — a colour swatch for the picker.
1098 let hidden = session.hiddenTracks.contains(ref)
1099 let focused = session.focusedTracks.contains(ref)
1100 let strip = NSRect(x: 0, y: lane.minY, width: headerW, height: lane.height)
1101 let color = hidden ? NSColor(calibratedWhite: 0.35, alpha: 1) : trackColor(ref)
1102 color.setFill()
1103 NSBezierPath(roundedRect: strip.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill()
1104 // Too short a lane can't fit the buttons without crowding — just show
1105 // the colour strip (hide/focus/reset stay on the right-click menu).
1106 guard lane.height >= Self.headerButtonsMinHeight else { return }
1107 let rects = headerButtonRects(lane: lane)
1108 drawHeaderButton(UI.hideSymbol, in: rects.hide, on: hidden)
1109 drawHeaderButton(UI.focusSymbol, in: rects.focus, on: focused)
1110 // The storyboard lane's hue is fixed — no colour picker for it.
1111 if let c = rects.color, ref != .storyboard {
1112 drawHeaderColorSwatch(in: c, hue: project.hue(for: ref))
1113 }
1114 }
1115
1116 static let headerButtonsMinHeight: CGFloat = 44
1117 /// Above this lane height the header also shows the colour swatch.
1118 static let headerColorMinHeight: CGFloat = 62
1119
1120 // The header swatch has no NSView to anchor the picker to, so drop an
1121 // invisible one over it for the duration (reuses the picker's positioning
1122 // + hover-corridor logic); it's removed when the picker closes.
1123 private var headerColorAnchor: NSView?
1124 private var headerColorSnapshot: ProjectModel?
1125
1126 private func openHeaderColorPicker(videoIndex: Int, swatchRect: NSRect) {
1127 headerColorAnchor?.removeFromSuperview()
1128 let anchor = NSView(frame: swatchRect)
1129 addSubview(anchor)
1130 headerColorAnchor = anchor
1131 let hue = project.hue(for: .video(videoIndex))
1132 let seed = NSColor(calibratedHue: hue, saturation: 0.7, brightness: 0.9, alpha: 1)
1133 // Live, non-undoable preview; commit as ONE undo step on close. No held
1134 // gesture, so editing the timeline mid-pick can't trip anything.
1135 headerColorSnapshot = store.project
1136 ColorPickerPanel.show(under: anchor, color: seed, onChange: { [weak self] c in
1137 guard let self,
1138 let h = c.usingColorSpace(.genericRGB)?.hueComponent else { return }
1139 self.store.preview { model in
1140 if model.tracks.indices.contains(videoIndex) {
1141 model.tracks[videoIndex].hue = h
1142 }
1143 }
1144 }, onClose: { [weak self] in
1145 guard let self else { return }
1146 if let snap = self.headerColorSnapshot {
1147 self.headerColorSnapshot = nil
1148 self.store.commitPreview(from: snap)
1149 }
1150 self.headerColorAnchor?.removeFromSuperview()
1151 self.headerColorAnchor = nil
1152 })
1153 }
1154
1155 private func drawFusionHeader() {
1156 guard fusionBandH > 0 else { return }
1157 let band = NSRect(x: 0, y: fusionTop, width: headerW, height: fusionBandH)
1158 FusionComps.yellow.withAlphaComponent(session.fusionHidden ? 0.25 : 0.6).setFill()
1159 NSBezierPath(roundedRect: band.insetBy(dx: 2, dy: 2), xRadius: 3, yRadius: 3).fill()
1160 let attrs: [NSAttributedString.Key: Any] = [
1161 .font: NSFont.systemFont(ofSize: 9, weight: .bold),
1162 .foregroundColor: NSColor.black.withAlphaComponent(0.8),
1163 ]
1164 let fSize = "F".size(withAttributes: attrs)
1165 "F".draw(at: NSPoint(x: headerW / 2 - fSize.width / 2, y: band.minY + 1),
1166 withAttributes: attrs)
1167 drawHeaderButton(UI.hideSymbol, centerY: band.minY + 19, on: session.fusionHidden)
1168 drawHeaderButton(UI.focusSymbol, centerY: band.minY + 37, on: session.fusionFocus)
1169 }
1170
1171 private func drawDragHintLanes() {
1172 // While dragging a clip below the last lane, hint the rows that would
1173 // become tracks. No resident placeholder lane. (Dropped FILES get their
1174 // own landing preview in drawFileDropPreview.)
1175 var rows: [Int] = []
1176 let count = laneRows.count
1177 if drag.mode == .move, let row = dragHintRow, row >= count {
1178 rows = Array(count...row)
1179 }
1180 for row in rows {
1181 let lane = laneRect(row: row).insetBy(dx: 2, dy: 2)
1182 guard lane.minY < bounds.maxY else { continue }
1183 let path = NSBezierPath(roundedRect: lane, xRadius: 4, yRadius: 4)
1184 path.setLineDash([4, 4], count: 2, phase: 0)
1185 Theme.dragHint.withAlphaComponent(0.6).setStroke()
1186 path.stroke()
1187 Theme.dragHint.withAlphaComponent(0.25).setFill()
1188 path.fill()
1189 }
1190 }
1191
1192 private func drawClip(_ clip: Clip, lane: NSRect, ref: TrackRef, colors: LaneColors,
1193 overlapping: Bool, hasPrevPanel: Bool,
1194 cullX0: CGFloat, cullX1: CGFloat) {
1195 let x0 = xFor(clip.start), x1 = xFor(clip.end)
1196 let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0), height: lane.height)
1197 guard rect.maxX > cullX0, rect.minX < cullX1 else { return }
1198 let media = clip.mediaId.flatMap { mediaById[$0] }
1199 let color = colors.base
1200 let selected = store.selection.contains(clip.id)
1201 let linkedSel = !selected && linkedSelectionCache.contains(clip.id)
1202
1203 // Storyboard panels tile edge-to-edge (they're gapless) so the track
1204 // reads as one continuous filmstrip — square corners, no per-panel
1205 // card, dividers drawn between shots below.
1206 let storyboard = clip.kind == .storyboard
1207
1208 func bodyFillColor() -> NSColor {
1209 switch clip.kind {
1210 case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1)
1211 case .audio: colors.audioBody
1212 case .video: media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1)
1213 : colors.videoBody
1214 }
1215 }
1216
1217 // ---- Fast path: a clip too narrow to resolve any detail. ----
1218 // Below `lodMinWidth` the rounded corners, strip text, badges and
1219 // filmstrip/waveform columns are physically invisible, so flat rect
1220 // fills produce the same picture without the bezier/clip-state chrome
1221 // that made dense zooms cost hundreds of ms. Everything that still
1222 // reads at this size is kept: body + strip colors, the audio center
1223 // line and fade handles (the texture of dense audio lanes), status
1224 // tint/borders, storyboard dividers.
1225 if rect.width < Self.lodMinWidth {
1226 bodyFillColor().setFill()
1227 rect.fill()
1228 colors.strip.setFill()
1229 NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: 13).fill()
1230 if clip.kind == .audio {
1231 color.withAlphaComponent(0.35).setFill()
1232 NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill()
1233 // Fade-handle slivers: the full path clips the 7 px handle
1234 // ovals to the clip body, so at this width only a couple of
1235 // white columns survive — the speckle texture of a dense audio
1236 // lane. Fill the same intersection directly.
1237 (selected ? NSColor.white : NSColor(calibratedWhite: 0.85, alpha: 0.9)).setFill()
1238 let hy = rect.minY + 13.5
1239 for x in [xFor(clip.start + clip.fadeIn), xFor(clip.end - clip.fadeOut)] {
1240 let sliver = NSRect(x: x - 3.5, y: hy, width: 7, height: 7)
1241 .intersection(rect)
1242 if !sliver.isEmpty { sliver.fill() }
1243 }
1244 }
1245 // Status tint only: at this width the border stroke's inset path is
1246 // degenerate (sub-zero width) and renders nothing today, and the
1247 // red overlap band is painted at lane level. The tint is what reads.
1248 if selected || linkedSel {
1249 (selected ? NSColor.controlAccentColor : NSColor.systemCyan)
1250 .withAlphaComponent(selected ? 0.34 : 0.20).setFill()
1251 rect.fill()
1252 }
1253 if storyboard { drawShotDivider(clip, rect: rect, hasPrev: hasPrevPanel) }
1254 return
1255 }
1256
1257 // A zoomed-in clip's rect can be literally millions of points wide;
1258 // building paths and image draws at that size costs real time even
1259 // though almost all of it is clipped away. Clamp the card geometry to
1260 // the view plus a margin that keeps the rounded corners and border
1261 // strokes of the clamped edges safely offscreen — the pixels inside
1262 // the view are identical. (The filmstrip keeps the TRUE rect: its
1263 // thumbnail tiling is phase-anchored to the clip's real left edge, and
1264 // it already culls to the visible span internally.)
1265 let clampMargin: CGFloat = 12
1266 let cardRect: NSRect = {
1267 let cx0 = max(rect.minX, cullX0 - clampMargin)
1268 let cx1 = min(rect.maxX, cullX1 + clampMargin)
1269 return NSRect(x: cx0, y: rect.minY, width: cx1 - cx0, height: rect.height)
1270 }()
1271
1272 let bodyRect = storyboard
1273 ? NSRect(x: cardRect.minX, y: cardRect.minY + 0.5,
1274 width: cardRect.width, height: cardRect.height - 1)
1275 : cardRect.insetBy(dx: 0.5, dy: 0.5)
1276 let bodyRadius: CGFloat = storyboard ? 0 : 3
1277 let body = DrawProf.t("clip.body") {
1278 let p = NSBezierPath(roundedRect: bodyRect, xRadius: bodyRadius,
1279 yRadius: bodyRadius)
1280 bodyFillColor().setFill()
1281 p.fill()
1282 return p
1283 }
1284
1285 DrawProf.t("clip.content") {
1286 NSGraphicsContext.current?.saveGraphicsState()
1287 body.addClip()
1288 switch clip.kind {
1289 case .video:
1290 if let media, session.showFilmstrips {
1291 drawFilmstrip(clip, media: media, rect: rect,
1292 cullX0: cullX0, cullX1: cullX1)
1293 }
1294 case .audio:
1295 if let media {
1296 drawWaveform(clip, media: media, rect: rect, color: color,
1297 cullX0: cullX0, cullX1: cullX1)
1298 }
1299 drawFades(clip, rect: rect, selected: selected)
1300 case .storyboard:
1301 drawBoardThumb(clip, rect: rect)
1302 }
1303 NSGraphicsContext.current?.restoreGraphicsState()
1304 }
1305
1306 // Title strip
1307 let title = clipTitle(clip, media: media)
1308 // The label is drawn only when the clip is wide enough to read; a clip
1309 // narrower than this shows no legible text anyway.
1310 let showLabel = rect.width >= 22
1311 DrawProf.t("clip.strip") {
1312 NSGraphicsContext.current?.saveGraphicsState()
1313 body.addClip()
1314 colors.strip.setFill()
1315 NSRect(x: cardRect.minX, y: cardRect.minY, width: cardRect.width, height: 13).fill()
1316 if showLabel {
1317 // Unpinned: the label sits at the clip's true left edge. A clip
1318 // scrolled off the left edge gets its label re-pinned to the view
1319 // edge by `drawPinnedLabels` — an overlay pass, so the pinning
1320 // never gets baked into a cached tile.
1321 drawTitle(title, at: NSPoint(x: rect.minX + 5, y: rect.minY + 1))
1322
1323 var badgeX = rect.maxX - 16
1324 if clip.speed != 1 {
1325 let s = String(format: "×%.4g", clip.speed)
1326 let sAttrs: [NSAttributedString.Key: Any] = [
1327 .font: NSFont.monospacedDigitSystemFont(ofSize: 8.5, weight: .semibold),
1328 .foregroundColor: FusionComps.yellow,
1329 ]
1330 let w = s.size(withAttributes: sAttrs).width
1331 badgeX -= w
1332 s.draw(at: NSPoint(x: badgeX, y: rect.minY + 1.5), withAttributes: sAttrs)
1333 badgeX -= 5
1334 }
1335 if clip.muted, let img = Self.mutedBadge {
1336 img.draw(in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10),
1337 from: .zero, operation: .sourceOver, fraction: 0.9)
1338 badgeX -= 13
1339 }
1340 if clip.linkId != nil, let img = Self.linkBadge {
1341 img.draw(in: NSRect(x: badgeX, y: rect.minY + 2, width: 10, height: 10),
1342 from: .zero, operation: .sourceOver, fraction: 0.7)
1343 }
1344 }
1345 NSGraphicsContext.current?.restoreGraphicsState()
1346 }
1347
1348 DrawProf.t("clip.chrome") {
1349 // Selection reads as a full-card tint, not just an outline. Link-mates
1350 // of the selection (they act selected) tint aqua.
1351 if selected || linkedSel {
1352 NSGraphicsContext.current?.saveGraphicsState()
1353 body.addClip()
1354 (selected ? NSColor.controlAccentColor : NSColor.systemCyan)
1355 .withAlphaComponent(selected ? 0.34 : 0.20).setFill()
1356 cardRect.fill()
1357 NSGraphicsContext.current?.restoreGraphicsState()
1358 }
1359
1360 // Border LAST, on top of the tint: overlap = red, selection = accent
1361 // (thick), link-mates = aqua. Storyboard panels skip the per-panel card
1362 // border — they use the shot dividers below — unless they need a status
1363 // outline (selected / linked / overlapping).
1364 if !storyboard || selected || linkedSel || overlapping {
1365 let radius: CGFloat = storyboard ? 0 : 3
1366 let border = NSBezierPath(roundedRect: cardRect.insetBy(dx: 1.25, dy: 1.25),
1367 xRadius: radius, yRadius: radius)
1368 border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5)
1369 (selected ? Theme.selection
1370 : linkedSel ? NSColor.systemCyan
1371 : overlapping ? NSColor.systemRed : color).setStroke()
1372 border.stroke()
1373 }
1374
1375 if storyboard { drawShotDivider(clip, rect: rect, hasPrev: hasPrevPanel) }
1376 }
1377 }
1378
1379 private func clipTitle(_ clip: Clip, media: MediaItem?) -> String {
1380 var title = media?.displayName
1381 ?? (clip.kind == .storyboard
1382 ? (panelNamesCache[clip.id] ?? "Panel") : "missing media")
1383 if clip.kind != .storyboard && media == nil { title = "⚠︎ " + title }
1384 if clip.kind == .audio { title = "♪ " + title }
1385 return title
1386 }
1387
1388 /// Re-pin the title of any clip whose left edge is scrolled offscreen: the
1389 /// label follows the view edge (as it always has), but the pinning is an
1390 /// overlay so it is never baked into a scene tile. The patch repaints the
1391 /// clip's own strip pixels under the new label position (covering the
1392 /// baked, unpinned label's tail), then restrokes the border segment it
1393 /// covered — output matches the old single-pass pinned render.
1394 private func drawPinnedLabels(ref: TrackRef, lane: NSRect) {
1395 let leftSec = secondsFor(headerW)
1396 let colors = laneColors(ref)
1397 let clips = clipsByLane[ref] ?? []
1398 // Only clips STRADDLING the left edge qualify; binary search the
1399 // candidate window instead of scanning every clip left of the view.
1400 for i in visibleIndexRange(clips, lane: ref, left: leftSec, right: leftSec) {
1401 let clip = clips[i]
1402 guard clip.start < leftSec, clip.end > leftSec else { continue }
1403 let x0 = xFor(clip.start), x1 = xFor(clip.end)
1404 let rect = NSRect(x: x0, y: lane.minY, width: max(2, x1 - x0),
1405 height: lane.height)
1406 guard rect.width >= 22 else { continue }
1407 let media = clip.mediaId.flatMap { mediaById[$0] }
1408 let title = clipTitle(clip, media: media)
1409 let selected = store.selection.contains(clip.id)
1410 let linkedSel = !selected && linkedSelectionCache.contains(clip.id)
1411 let overlapping = (overlapIdsByLane[ref] ?? []).contains(clip.id)
1412
1413 let labelW = title.size(withAttributes: Self.titleAttrs).width
1414 let patch = NSRect(x: headerW, y: rect.minY,
1415 width: min(labelW + 12, rect.maxX - headerW), height: 13)
1416 let storyboard = clip.kind == .storyboard
1417 let cx1 = min(rect.maxX, bounds.width + 12)
1418 let cardRect = NSRect(x: max(rect.minX, headerW - 12), y: rect.minY,
1419 width: cx1 - max(rect.minX, headerW - 12),
1420 height: rect.height)
1421 let bodyRect = storyboard
1422 ? NSRect(x: cardRect.minX, y: cardRect.minY + 0.5,
1423 width: cardRect.width, height: cardRect.height - 1)
1424 : cardRect.insetBy(dx: 0.5, dy: 0.5)
1425 let radius: CGFloat = storyboard ? 0 : 3
1426 let body = NSBezierPath(roundedRect: bodyRect, xRadius: radius, yRadius: radius)
1427
1428 NSGraphicsContext.current?.saveGraphicsState()
1429 NSRect(x: patch.minX, y: patch.minY, width: patch.width,
1430 height: patch.height).clip()
1431 body.addClip()
1432 // Rebuild the strip composite: body color under the 0.85-alpha strip.
1433 switch clip.kind {
1434 case .storyboard: NSColor(calibratedWhite: 0.88, alpha: 1).setFill()
1435 case .audio: colors.audioBody.setFill()
1436 case .video: (media == nil ? NSColor(calibratedWhite: 0.25, alpha: 1)
1437 : colors.videoBody).setFill()
1438 }
1439 patch.fill()
1440 colors.strip.setFill()
1441 patch.fill()
1442 drawTitle(title, at: NSPoint(x: headerW + 5, y: rect.minY + 1))
1443 if selected || linkedSel {
1444 (selected ? NSColor.controlAccentColor : NSColor.systemCyan)
1445 .withAlphaComponent(selected ? 0.34 : 0.20).setFill()
1446 patch.fill()
1447 }
1448 NSGraphicsContext.current?.restoreGraphicsState()
1449
1450 // Restroke the border segment the patch painted over — clipped to
1451 // the patch only (drawClip strokes the border unclipped, so a
1452 // body clip here would shave its outer antialiasing).
1453 if !storyboard || selected || linkedSel || overlapping {
1454 NSGraphicsContext.current?.saveGraphicsState()
1455 patch.insetBy(dx: 0, dy: -1).clip()
1456 let border = NSBezierPath(
1457 roundedRect: cardRect.insetBy(dx: 1.25, dy: 1.25),
1458 xRadius: radius, yRadius: radius)
1459 border.lineWidth = selected ? 3.5 : linkedSel ? 3 : (overlapping ? 2 : 1.5)
1460 (selected ? Theme.selection
1461 : linkedSel ? NSColor.systemCyan
1462 : overlapping ? NSColor.systemRed : colors.base).setStroke()
1463 border.stroke()
1464 NSGraphicsContext.current?.restoreGraphicsState()
1465 }
1466 }
1467 }
1468
1469 /// Shot divider: an opaque line sitting ON the boundary between two
1470 /// storyboard panels (they tile gaplessly). A new shot gets a bold
1471 /// orange bar; frames within a shot get a thin neutral line. The first
1472 /// panel of the track has no divider on its left.
1473 private func drawShotDivider(_ clip: Clip, rect: NSRect, hasPrev: Bool) {
1474 guard hasPrev else { return }
1475 if clip.newShot {
1476 NSColor.systemOrange.setFill()
1477 NSRect(x: rect.minX - 1.5, y: rect.minY, width: 3, height: rect.height).fill()
1478 } else {
1479 NSColor(calibratedWhite: Theme.light ? 0.45 : 0.30, alpha: 1).setFill()
1480 NSRect(x: rect.minX - 0.5, y: rect.minY, width: 1, height: rect.height).fill()
1481 }
1482 }
1483
1484 private func drawFilmstrip(_ clip: Clip, media: MediaItem, rect: NSRect,
1485 cullX0: CGFloat, cullX1: CGFloat) {
1486 let thumbH = rect.height - 14
1487 guard thumbH > 6 else { return }
1488 let mediaAspect = media.width > 0 && media.height > 0
1489 ? CGFloat(media.width) / CGFloat(media.height) : 16.0 / 9.0
1490 let thumbW = thumbH * mediaAspect
1491 // Thumb tiling stays phase-anchored to the clip's TRUE left edge (so
1492 // thumbnails never slide as the window changes); the window only culls.
1493 let visX0 = max(rect.minX, cullX0), visX1 = min(rect.maxX, cullX1)
1494 guard let cg = NSGraphicsContext.current?.cgContext else { return }
1495 var x = rect.minX + floor((visX0 - rect.minX) / thumbW) * thumbW
1496 while x < visX1 {
1497 let tlSec = secondsFor(x + thumbW / 2)
1498 let srcSec = clip.sourceTime(at: tlSec)
1499 if let img = MediaPipeline.shared.filmstripImage(for: media, at: max(0, srcSec)) {
1500 if DrawProf.on { DrawProf.thumbHits += 1 }
1501 // CGContext blit with a local flip (CGImages draw bottom-up;
1502 // the view is flipped). The images are pre-converted to the
1503 // display's format, so this is a plain copy.
1504 cg.saveGState()
1505 cg.setAlpha(0.9)
1506 cg.translateBy(x: x, y: rect.minY + 14 + thumbH)
1507 cg.scaleBy(x: 1, y: -1)
1508 cg.draw(img, in: CGRect(x: 0, y: 0, width: thumbW, height: thumbH))
1509 cg.restoreGState()
1510 }
1511 x += thumbW
1512 }
1513 trackColorForClip(clip).withAlphaComponent(0.10).setFill()
1514 rect.fill()
1515 }
1516
1517 private func drawWaveform(_ clip: Clip, media: MediaItem, rect: NSRect, color: NSColor,
1518 cullX0: CGFloat, cullX1: CGFloat) {
1519 // Center line
1520 color.withAlphaComponent(0.35).setFill()
1521 NSRect(x: rect.minX, y: rect.midY + 6, width: rect.width, height: 1).fill()
1522 guard let img = MediaPipeline.shared.waveformImage(for: media), media.duration > 0
1523 else { return }
1524 // Draw only the visible span — a zoomed-in clip's full rect can be
1525 // millions of points wide, and image draws at that size are slow even
1526 // though it's all clipped away. CGImage has no source-rect draw, so
1527 // map the FULL image through the source→dest transform and clip to
1528 // the visible span: pixel-identical to a fractional source rect
1529 // (cropping(to:) would round to whole source pixels), and Quartz only
1530 // rasterizes the clipped part.
1531 let visX0 = max(rect.minX, cullX0 - 2), visX1 = min(rect.maxX, cullX1 + 2)
1532 guard visX1 > visX0, let cg = NSGraphicsContext.current?.cgContext else { return }
1533 let imgW = CGFloat(img.width)
1534 let fromX = CGFloat(clip.srcIn / media.duration) * imgW
1535 let fromW = max(1, CGFloat(clip.duration / media.duration) * imgW)
1536 let dest = NSRect(x: rect.minX, y: rect.minY + 14,
1537 width: rect.width, height: rect.height - 16)
1538 let scaleX = dest.width / fromW
1539 cg.saveGState()
1540 cg.setAlpha(0.85)
1541 cg.clip(to: CGRect(x: visX0, y: dest.minY, width: visX1 - visX0,
1542 height: dest.height))
1543 cg.translateBy(x: dest.minX - fromX * scaleX, y: dest.maxY)
1544 cg.scaleBy(x: 1, y: -1)
1545 cg.draw(img, in: CGRect(x: 0, y: 0, width: imgW * scaleX, height: dest.height))
1546 cg.restoreGState()
1547 }
1548
1549 private func drawFades(_ clip: Clip, rect: NSRect, selected: Bool) {
1550 let top = rect.minY + 13, bottom = rect.maxY
1551 func fadeShape(from x0: CGFloat, to x1: CGFloat, leading: Bool) {
1552 guard abs(x1 - x0) > 0.5 else { return }
1553 let path = NSBezierPath()
1554 path.move(to: NSPoint(x: x0, y: bottom))
1555 path.line(to: NSPoint(x: x1, y: top))
1556 path.line(to: NSPoint(x: leading ? x0 : x1, y: top))
1557 path.close()
1558 NSColor.black.withAlphaComponent(0.45).setFill()
1559 path.fill()
1560 let line = NSBezierPath()
1561 line.move(to: NSPoint(x: x0, y: bottom))
1562 line.line(to: NSPoint(x: x1, y: top))
1563 NSColor.white.withAlphaComponent(0.8).setStroke()
1564 line.lineWidth = 1.2
1565 line.stroke()
1566 }
1567 let xIn = xFor(clip.start + clip.fadeIn)
1568 let xOut = xFor(clip.end - clip.fadeOut)
1569 if clip.fadeIn > 0.001 { fadeShape(from: xFor(clip.start), to: xIn, leading: true) }
1570 if clip.fadeOut > 0.001 { fadeShape(from: xFor(clip.end), to: xOut, leading: false) }
1571 drawFadeHandles(clip, rect: rect, selected: selected)
1572 }
1573
1574 /// Fade handles (always visible so fades stay discoverable). Split out of
1575 /// `drawFades` because narrow (LOD) clips draw the handles — they're the
1576 /// speckled texture of a dense audio lane — without the fade shapes.
1577 private func drawFadeHandles(_ clip: Clip, rect: NSRect, selected: Bool) {
1578 let top = rect.minY + 13
1579 let xIn = xFor(clip.start + clip.fadeIn)
1580 let xOut = xFor(clip.end - clip.fadeOut)
1581 for x in [xIn, xOut] {
1582 let r = NSRect(x: x - 3.5, y: top - 3.5 + 4, width: 7, height: 7)
1583 (selected ? NSColor.white : NSColor(calibratedWhite: 0.85, alpha: 0.9)).setFill()
1584 NSBezierPath(ovalIn: r).fill()
1585 }
1586 }
1587
1588 private func drawBoardThumb(_ clip: Clip, rect: NSRect) {
1589 guard let board = clip.board, rect.height > 20 else { return }
1590 let img = boards.composite(for: board)
1591 let h = rect.height - 15
1592 let w = h * CGFloat(board.width / board.height)
1593 var x = rect.minX + 1
1594 while x < rect.maxX - 1 {
1595 img.draw(in: NSRect(x: x, y: rect.minY + 14, width: min(w, rect.maxX - 1 - x),
1596 height: h),
1597 from: NSRect(x: 0, y: 0,
1598 width: img.size.width * min(1, (rect.maxX - 1 - x) / w),
1599 height: img.size.height),
1600 operation: .sourceOver, fraction: 1)
1601 x += w + 2
1602 break // one panel image; boards are one still, no need to tile
1603 }
1604 Self.bakedSymbol("pencil.and.outline",
1605 tint: NSColor(calibratedWhite: 0.2, alpha: 1),
1606 key: "board.pencil")?
1607 .draw(in: NSRect(x: rect.minX + 4, y: rect.minY + 16, width: 11, height: 11),
1608 from: .zero, operation: .sourceOver, fraction: 0.9)
1609 }
1610
1611 // MARK: - Fusion comps band
1612
1613 private func drawFusionBand() {
1614 guard fusionBandH > 0 else { return }
1615 let band = NSRect(x: 0, y: fusionTop, width: bounds.width, height: fusionBandH)
1616 NSColor(calibratedRed: 0.16, green: 0.14, blue: 0.05, alpha: 1).setFill()
1617 band.fill()
1618 FusionComps.yellow.withAlphaComponent(0.5).setFill()
1619 NSRect(x: 0, y: band.maxY - 1, width: bounds.width, height: 1).fill()
1620
1621 let fps = project.fps
1622 let preferred = Set(project.preferredTakes)
1623 for (comp, lane, lanes) in comps.stacked() {
1624 let x0 = xFor(comp.startSeconds(fps: fps))
1625 let x1 = xFor(comp.endSeconds(fps: fps))
1626 guard x1 > headerW, x0 < bounds.width else { continue }
1627 let subH = (band.height - 6) / CGFloat(lanes)
1628 let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH,
1629 width: max(2, x1 - x0), height: subH - 1)
1630 let isPreferred = preferred.contains(comp.name)
1631 let selected = comps.selectedCompPath == comp.path
1632 FusionComps.yellow.withAlphaComponent(isPreferred ? 0.95 : 0.55).setFill()
1633 let p = NSBezierPath(roundedRect: r.insetBy(dx: 0.5, dy: 0.5), xRadius: 3, yRadius: 3)
1634 p.fill()
1635 if selected {
1636 Theme.selection.setStroke()
1637 let b = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: 3, yRadius: 3)
1638 b.lineWidth = 2
1639 b.stroke()
1640 }
1641 var label = comp.title.isEmpty ? comp.name : comp.title
1642 if isPreferred { label = "★ " + label }
1643 let attrs: [NSAttributedString.Key: Any] = [
1644 .font: NSFont.systemFont(ofSize: min(10, subH - 4), weight: .semibold),
1645 .foregroundColor: NSColor.black.withAlphaComponent(0.8),
1646 ]
1647 NSGraphicsContext.current?.saveGraphicsState()
1648 p.addClip()
1649 label.draw(at: NSPoint(x: max(x0, headerW) + 4,
1650 y: r.midY - label.size(withAttributes: attrs).height / 2),
1651 withAttributes: attrs)
1652 NSGraphicsContext.current?.restoreGraphicsState()
1653 }
1654 }
1655
1656 private func compAt(point: NSPoint) -> FusionComp? {
1657 guard fusionBandH > 0, point.y > fusionTop, point.y < fusionTop + fusionBandH
1658 else { return nil }
1659 let fps = project.fps
1660 let band = NSRect(x: 0, y: fusionTop, width: bounds.width, height: fusionBandH)
1661 for (comp, lane, lanes) in comps.stacked() {
1662 let x0 = xFor(comp.startSeconds(fps: fps))
1663 let x1 = xFor(comp.endSeconds(fps: fps))
1664 let subH = (band.height - 6) / CGFloat(lanes)
1665 let r = NSRect(x: x0, y: band.minY + 3 + CGFloat(lane) * subH,
1666 width: max(2, x1 - x0), height: subH - 1)
1667 if r.contains(point) { return comp }
1668 }
1669 return nil
1670 }
1671
1672 // MARK: - Optimization strip
1673
1674 /// Per-column optimization state for the thin band under the ruler:
1675 /// GREEN — every proxy chunk the visible tracks need at that time is
1676 /// built at the preview-quality target; DIM GREEN — built but below
1677 /// target (usable now, upgrade queued); YELLOW — building right now (or
1678 /// a rescue slice is standing in); RED — no proxy on disk (never built,
1679 /// evicted, or failed). Aggregated worst-wins across visible video
1680 /// tracks, so one glance says whether playing here will be instant.
1681 ///
1682 /// The runs are cached — recomputed only when the view moves (origin/
1683 /// zoom/width) or something actually changed (.projectChanged /
1684 /// .mediaStatusChanged clear the key) — so playback frames just re-fill
1685 /// a few dozen rects.
1686 private var stripRuns: [(x: CGFloat, w: CGFloat, color: NSColor)] = []
1687 private var stripKey: (origin: Double, pps: Double, width: CGFloat)?
1688
1689 private func rebuildStripIfNeeded() {
1690 if let k = stripKey, k.origin == originSecond, k.pps == pxPerSecond,
1691 k.width == bounds.width { return }
1692 stripKey = (originSecond, pxPerSecond, bounds.width)
1693 stripRuns.removeAll(keepingCapacity: true)
1694 rebuildSceneIfNeeded()
1695
1696 let colW: CGFloat = 2
1697 let ncols = Int((bounds.width - headerW) / colW) + 1
1698 guard ncols > 0 else { return }
1699 var vals = [UInt8](repeating: .max, count: ncols)
1700 let leftSec = secondsFor(headerW), rightSec = secondsFor(bounds.width)
1701 var snaps: [String: ChunkManager.StripSnapshot?] = [:]
1702
1703 func mark(_ lo: Double, _ hi: Double, _ v: UInt8) {
1704 let c0 = max(0, Int((xFor(lo) - headerW) / colW))
1705 let c1 = min(ncols - 1, Int((xFor(hi) - headerW) / colW))
1706 guard c1 >= c0 else { return }
1707 for c in c0...c1 where vals[c] > v { vals[c] = v }
1708 }
1709
1710 for ref in laneRows {
1711 guard ref.videoIndex != nil, !session.hiddenTracks.contains(ref) else { continue }
1712 let clips = clipsByLane[ref] ?? []
1713 for i in visibleIndexRange(clips, lane: ref, left: leftSec, right: rightSec) {
1714 let clip = clips[i]
1715 guard clip.kind == .video,
1716 let media = project.media(clip.mediaId), !media.isAudio,
1717 media.duration > 0 else { continue }
1718 let a = max(clip.start, leftSec), b = min(clip.end, rightSec)
1719 guard b > a else { continue }
1720 let snap: ChunkManager.StripSnapshot?
1721 if let cached = snaps[media.cacheKey] { snap = cached }
1722 else { snap = ctx.chunks.stripSnapshot(media: media); snaps[media.cacheKey] = snap }
1723 guard let snap else { mark(a, b, 0); continue } // unscanned: unknown
1724 if snap.fullProxy { mark(a, b, 3); continue } // legacy whole-file proxy
1725 let srcA = clip.srcIn + (a - clip.start) * clip.speed
1726 let srcB = clip.srcIn + (b - clip.start) * clip.speed
1727 let ci = min(snap.n - 1, ChunkManager.chunkIndex(forSource: min(srcA, srcB)))
1728 let cj = min(snap.n - 1, ChunkManager.chunkIndex(forSource: max(srcA, srcB) - 1e-6))
1729 for idx in ci...max(ci, cj) {
1730 let v: UInt8
1731 if let w = snap.built[idx] {
1732 v = w >= snap.target ? 3 : 2
1733 } else if snap.inFlight.contains(idx) || snap.partial.contains(idx) {
1734 v = 1
1735 } else {
1736 v = 0 // missing, evicted, or failed
1737 }
1738 let t0 = clip.start
1739 + (Double(idx) * ChunkManager.chunkSeconds - clip.srcIn) / max(1e-4, clip.speed)
1740 let t1 = clip.start
1741 + (Double(idx + 1) * ChunkManager.chunkSeconds - clip.srcIn) / max(1e-4, clip.speed)
1742 mark(max(min(t0, t1), a), min(max(t0, t1), b), v)
1743 }
1744 }
1745 }
1746
1747 // Compress equal-valued columns into fill runs.
1748 let palette = [Theme.stripMissing, Theme.stripBuilding,
1749 Theme.stripUsable, Theme.stripFull]
1750 var c = 0
1751 while c < ncols {
1752 let v = vals[c]
1753 var e = c + 1
1754 while e < ncols, vals[e] == v { e += 1 }
1755 if v != .max {
1756 stripRuns.append((x: headerW + CGFloat(c) * colW,
1757 w: CGFloat(e - c) * colW,
1758 color: palette[Int(v)]))
1759 }
1760 c = e
1761 }
1762 }
1763
1764 private func drawOptimizeStrip() {
1765 Theme.timelineBg.setFill()
1766 NSRect(x: 0, y: rulerH, width: bounds.width, height: stripH).fill()
1767 rebuildStripIfNeeded()
1768 let y = rulerH + 1
1769 for run in stripRuns {
1770 run.color.setFill()
1771 NSRect(x: run.x, y: y, width: run.w, height: stripH - 2).fill()
1772 }
1773 }
1774
1775 // MARK: - Ruler / playhead / indicators
1776
1777 private func drawRuler() {
1778 Theme.rulerBg.setFill()
1779 NSRect(x: 0, y: 0, width: bounds.width, height: rulerH).fill()
1780 Theme.rulerLine.setFill()
1781 NSRect(x: 0, y: rulerH - 1, width: bounds.width, height: 1).fill()
1782
1783 let steps: [Double] = [0.04, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800]
1784 let major = steps.first { $0 * pxPerSecond >= 70 } ?? 3600
1785 let labelAttrs: [NSAttributedString.Key: Any] = [
1786 .font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
1787 .foregroundColor: Theme.subtleLabel,
1788 ]
1789 var s = (originSecond / major).rounded(.down) * major
1790 while xFor(s) < bounds.width {
1791 let x = xFor(s)
1792 if x >= headerW, s >= 0 {
1793 Theme.tickMajor.setFill()
1794 NSRect(x: x, y: rulerH - 8, width: 1, height: 8).fill()
1795 label(for: s).draw(at: NSPoint(x: x + 3, y: 3), withAttributes: labelAttrs)
1796 // minor ticks
1797 for m in 1..<5 {
1798 let mx = xFor(s + major * Double(m) / 5)
1799 Theme.tickMinor.setFill()
1800 NSRect(x: mx, y: rulerH - 4, width: 1, height: 4).fill()
1801 }
1802 }
1803 s += major
1804 }
1805 }
1806
1807 private func label(for seconds: Double) -> String {
1808 let total = Int(seconds.rounded())
1809 if seconds < 1 && seconds > 0 { return String(format: "%.2f", seconds) }
1810 let h = total / 3600, m = (total / 60) % 60, sec = total % 60
1811 return h > 0 ? String(format: "%d:%02d:%02d", h, m, sec) : String(format: "%d:%02d", m, sec)
1812 }
1813
1814 /// The loop range. With both ends set, a tinted band over the lanes between
1815 /// in and out plus `[` / `]` bracket handles in the ruler. A lone point
1816 /// (only in, or only out) shows just an arrow flag in the ruler — like a
1817 /// marker — rather than tinting the whole timeline. Green while cycling,
1818 /// amber when set but looping is off. Drawn under markers and playhead.
1819 private func drawInOut() {
1820 let pc = playback
1821 guard pc.hasInOut else { return }
1822 let color = pc.loops ? NSColor.systemGreen : NSColor.systemOrange
1823
1824 // Lone point: draw a flag, no band.
1825 guard let i0 = pc.inPoint, let o0 = pc.outPoint else {
1826 if let i = pc.inPoint { drawInOutFlag(at: i, color: color, isIn: true) }
1827 if let o = pc.outPoint { drawInOutFlag(at: o, color: color, isIn: false) }
1828 return
1829 }
1830
1831 // Full range: tinted band over the lanes.
1832 let xLo = max(headerW, xFor(i0))
1833 let xHi = min(bounds.width, xFor(o0))
1834 if xHi > xLo {
1835 color.withAlphaComponent(0.10).setFill()
1836 NSRect(x: xLo, y: rulerH, width: xHi - xLo, height: bounds.height - rulerH).fill()
1837 color.withAlphaComponent(0.85).setFill()
1838 NSRect(x: xLo, y: rulerH - 3, width: xHi - xLo, height: 3).fill()
1839 }
1840 // In bracket: stem with feet pointing right (into the range).
1841 let xi = xFor(i0)
1842 if xi >= headerW - 2, xi <= bounds.width {
1843 color.setFill()
1844 NSRect(x: xi, y: 0, width: 2, height: rulerH).fill()
1845 NSRect(x: xi, y: rulerH - 3, width: 7, height: 3).fill()
1846 NSRect(x: xi, y: 0, width: 7, height: 3).fill()
1847 }
1848 // Out bracket: feet point left.
1849 let xo = xFor(o0)
1850 if xo >= headerW, xo <= bounds.width + 2 {
1851 color.setFill()
1852 NSRect(x: xo - 2, y: 0, width: 2, height: rulerH).fill()
1853 NSRect(x: xo - 7, y: rulerH - 3, width: 7, height: 3).fill()
1854 NSRect(x: xo - 7, y: 0, width: 7, height: 3).fill()
1855 }
1856 }
1857
1858 /// A lone in/out point reads as a triangular arrow flag in the ruler plus a
1859 /// thin stem down the lanes, like a marker. The arrow points into the range:
1860 /// right for an in point, left for an out point.
1861 private func drawInOutFlag(at t: Double, color: NSColor, isIn: Bool) {
1862 let x = xFor(t)
1863 guard x >= headerW - 2, x <= bounds.width + 2 else { return }
1864 let stemX = isIn ? x : x - 1
1865 // Faint pole down the lanes.
1866 color.withAlphaComponent(0.5).setFill()
1867 NSRect(x: stemX, y: rulerH, width: 1, height: bounds.height - rulerH).fill()
1868 // Triangular arrow in the ruler, anchored on the exact time.
1869 let h: CGFloat = 13
1870 let top = rulerH - h - 1
1871 let beak: CGFloat = 8
1872 color.setFill()
1873 NSRect(x: stemX, y: top, width: 1, height: rulerH - top).fill()
1874 let arrow = NSBezierPath()
1875 arrow.move(to: NSPoint(x: x, y: top))
1876 arrow.line(to: NSPoint(x: x + (isIn ? beak : -beak), y: top + h / 2))
1877 arrow.line(to: NSPoint(x: x, y: top + h))
1878 arrow.close()
1879 arrow.fill()
1880 }
1881
1882 private func drawPlayhead() {
1883 let x = xFor(playback.playhead)
1884 guard x >= headerW, x <= bounds.width else { return }
1885 NSColor.systemRed.withAlphaComponent(0.9).setFill()
1886 NSRect(x: x, y: 0, width: 1.5, height: bounds.height).fill()
1887 let tri = NSBezierPath()
1888 tri.move(to: NSPoint(x: x - 5, y: 0))
1889 tri.line(to: NSPoint(x: x + 6.5, y: 0))
1890 tri.line(to: NSPoint(x: x + 0.75, y: 8))
1891 tri.close()
1892 tri.fill()
1893 }
1894
1895 private static let markerLabelAttrs: [NSAttributedString.Key: Any] = [
1896 .font: NSFont.systemFont(ofSize: 9, weight: .semibold),
1897 .foregroundColor: NSColor.white,
1898 ]
1899
1900 /// Blue markers: a thin line down the lanes plus a clickable flag in the
1901 /// ruler. A named marker carries its name inside the flag itself; an
1902 /// unnamed one gets a small plain pennant. Drawn under the red playhead.
1903 private func drawMarkers() {
1904 let blue = NSColor.systemBlue
1905 for m in project.sortedMarkers {
1906 let x = xFor(m.time)
1907 guard x >= headerW, x <= bounds.width else { continue }
1908 // Flag hanging off the pole: a straight left edge on the pole and a
1909 // triangular pennant point on the right. A named marker carries the
1910 // name inside; an unnamed one is the same-height flag, just narrow.
1911 let h: CGFloat = 13
1912 let top = rulerH - h - 1
1913 // Pole: a faint line down the lanes, plus a solid stem only as tall
1914 // as the flag (not running up to the top of the ruler).
1915 blue.withAlphaComponent(0.5).setFill()
1916 NSRect(x: x, y: rulerH, width: 1, height: bounds.height - rulerH).fill()
1917 blue.withAlphaComponent(0.95).setFill()
1918 NSRect(x: x, y: top, width: 1, height: rulerH - top).fill()
1919 let beak: CGFloat = 6
1920 let body: CGFloat = m.label.isEmpty
1921 ? 8
1922 : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 10
1923 let flag = NSBezierPath()
1924 flag.move(to: NSPoint(x: x + 1, y: top)) // top-left
1925 flag.line(to: NSPoint(x: x + 1 + body, y: top)) // top-right
1926 flag.line(to: NSPoint(x: x + 1 + body + beak, y: top + h / 2)) // point
1927 flag.line(to: NSPoint(x: x + 1 + body, y: top + h)) // bottom-right
1928 flag.line(to: NSPoint(x: x + 1, y: top + h)) // bottom-left
1929 flag.close()
1930 flag.fill()
1931 if !m.label.isEmpty {
1932 (m.label as NSString).draw(at: NSPoint(x: x + 6, y: top + 2),
1933 withAttributes: Self.markerLabelAttrs)
1934 }
1935 }
1936 }
1937
1938 /// Hit region for a marker's ruler flag (click to seek, double-click to
1939 /// rename, right-click for its menu).
1940 private func markerHandleRect(_ m: Marker) -> NSRect {
1941 // Cover the whole flag: pole + body + beak (see drawMarkers).
1942 let w: CGFloat = m.label.isEmpty
1943 ? 20
1944 : (m.label as NSString).size(withAttributes: Self.markerLabelAttrs).width + 21
1945 return NSRect(x: xFor(m.time) - 4, y: 0, width: w, height: rulerH)
1946 }
1947 /// Topmost marker flag under a ruler point, if any.
1948 private func markerAt(point p: NSPoint) -> Marker? {
1949 guard p.y <= rulerH else { return nil }
1950 return project.sortedMarkers.reversed().first { markerHandleRect($0).contains(p) }
1951 }
1952
1953 private func drawSnapIndicator() {
1954 guard let target = activeSnapTarget else { return }
1955 let x = xFor(target)
1956 NSColor.systemYellow.withAlphaComponent(0.8).setFill()
1957 NSRect(x: x, y: 0, width: 1, height: bounds.height).fill()
1958 }
1959
1960 private func drawBoxSelect() {
1961 guard drag.mode == .box, drag.moved else { return }
1962 let r = boxRect()
1963 Theme.label.withAlphaComponent(0.08).setFill()
1964 r.fill()
1965 Theme.label.withAlphaComponent(0.6).setStroke()
1966 let p = NSBezierPath(rect: r)
1967 p.lineWidth = 1
1968 p.stroke()
1969 }
1970
1971 private func boxRect() -> NSRect {
1972 NSRect(x: min(drag.startPoint.x, lastMousePoint.x),
1973 y: min(drag.startPoint.y, lastMousePoint.y),
1974 width: abs(lastMousePoint.x - drag.startPoint.x),
1975 height: abs(lastMousePoint.y - drag.startPoint.y))
1976 }
1977
1978 // MARK: - Mouse editing
1979
1980 private enum DragMode {
1981 case none, scrub, move, trimIn, trimOut, rippleOut, rippleIn, slip,
1982 stretchIn, stretchOut, fadeIn, fadeOut, box, resizeTrack,
1983 hBarPan, hBarLeft, hBarRight, vBarPan, vBarTop, vBarBottom
1984 }
1985
1986 private struct BarDrag {
1987 var domainLo = 0.0, domainHi = 0.0
1988 var origOrigin = 0.0
1989 var origPps = 20.0
1990 var origScrollY: CGFloat = 0
1991 var origScale: CGFloat = 1
1992 var origContentH: CGFloat = 1
1993 var thumb = NSRect.zero
1994 }
1995 private var barDrag = BarDrag()
1996 private struct DragState {
1997 var mode: DragMode = .none
1998 var clipId: UUID?
1999 var startPoint = NSPoint.zero
2000 var origClip: Clip?
2001 var origSelection: [UUID: Clip] = [:]
2002 var baseSelection: Set<UUID> = [] // box select: selection before drag
2003 var resizeRow: Int?
2004 var resizeOrigH: CGFloat = 0
2005 var moved = false
2006 var collapseTo: UUID? // click-on-selected: reduce to this clip if no drag
2007 }
2008 private var drag = DragState()
2009
2010 // Middle-button drag grabs the timeline and hand-pans it in both axes
2011 // (time horizontally, tracks vertically), independent of any clip edit.
2012 private struct PanDrag {
2013 var active = false
2014 var start = NSPoint.zero
2015 var origOrigin = 0.0
2016 var origScrollY: CGFloat = 0
2017 }
2018 private var panDrag = PanDrag()
2019
2020 private var activeSnapTarget: Double?
2021 private var dragHintRow: Int?
2022 private var lastMousePoint = NSPoint.zero
2023
2024 override func mouseDown(with event: NSEvent) {
2025 window?.makeFirstResponder(self)
2026 let p = convert(event.locationInWindow, from: nil)
2027 lastMousePoint = p
2028 drag = DragState()
2029 drag.startPoint = p
2030
2031 if let barMode = scrollbarHit(p) {
2032 drag.mode = barMode
2033 let (lo, hi) = hDomain()
2034 barDrag = BarDrag(domainLo: lo, domainHi: hi,
2035 origOrigin: originSecond, origPps: pxPerSecond,
2036 origScrollY: scrollY, origScale: session.laneScale,
2037 origContentH: lanesContentHeight,
2038 thumb: [.vBarPan, .vBarTop, .vBarBottom].contains(barMode)
2039 ? vThumbRect() : hThumbRect())
2040 return
2041 }
2042
2043 // A marker flag in the ruler: click parks the playhead on it. Rename
2044 // via its right-click menu (name field is inline there). Checked
2045 // before the scrub fallthrough.
2046 if p.y < rulerH, let m = markerAt(point: p) {
2047 playback.setRate(0)
2048 playback.seek(to: m.time)
2049 return
2050 }
2051
2052 if p.y < rulerH + stripH { // ruler + optimization strip both scrub
2053 drag.mode = .scrub
2054 playback.setRate(0)
2055 playback.seek(to: max(0, quantize(secondsFor(p.x))))
2056 return
2057 }
2058
2059 // Fusion band header: hide preview (top) / focus (bottom)
2060 if fusionBandH > 0, p.x < headerW, p.y > fusionTop, p.y < lanesTop {
2061 if p.y < fusionTop + fusionBandH * 0.55 { session.fusionHidden.toggle() }
2062 else { session.fusionFocus.toggle() }
2063 needsDisplay = true
2064 return
2065 }
2066 // Fusion comps band
2067 if let comp = compAt(point: p) {
2068 comps.selectedCompPath = comp.path
2069 if event.clickCount == 2 { comps.openInFusion(comp) }
2070 needsDisplay = true
2071 return
2072 }
2073 if p.y < lanesTop { return }
2074
2075 // Track header buttons
2076 if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) {
2077 let lane = laneRect(row: row)
2078 guard lane.height >= Self.headerButtonsMinHeight else { return } // buttons hidden
2079 let rects = headerButtonRects(lane: lane)
2080 if let c = rects.color, ref.videoIndex != nil, c.insetBy(dx: -2, dy: -2).contains(p) {
2081 openHeaderColorPicker(videoIndex: ref.videoIndex!, swatchRect: c)
2082 } else if p.y < lane.midY {
2083 session.toggleHidden(ref)
2084 } else {
2085 session.toggleFocus(ref)
2086 }
2087 needsDisplay = true
2088 return
2089 }
2090
2091 // Track height resize on lane boundaries
2092 if let row = trackBoundaryAt(y: p.y), let ref = laneRef(row: row) {
2093 drag.mode = .resizeTrack
2094 drag.resizeRow = row
2095 drag.resizeOrigH = laneHeight(ref)
2096 return
2097 }
2098
2099 // Clicking a red overlap selects both offenders (S then resolves it).
2100 if let o = overlapAt(point: p), clipAt(point: p) != nil {
2101 store.selection = [o.a.id, o.b.id]
2102 drag.mode = .move
2103 drag.clipId = o.b.id
2104 drag.origClip = o.b
2105 drag.origSelection = Dictionary(uniqueKeysWithValues:
2106 project.clips.filter { store.selection.contains($0.id) }.map { ($0.id, $0) })
2107 store.beginGesture()
2108 return
2109 }
2110
2111 guard let (clip, row) = clipAt(point: p) else {
2112 // Empty area: box select (click without drag = deselect).
2113 drag.mode = .box
2114 drag.baseSelection = event.modifierFlags.contains(.shift) ? store.selection : []
2115 store.selection = drag.baseSelection
2116 return
2117 }
2118
2119 if event.clickCount == 2, clip.kind == .storyboard {
2120 store.selection = [clip.id]
2121 StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx)
2122 return
2123 }
2124
2125 // Blade tool: click a clip to split it right there.
2126 if session.mainTool == .blade {
2127 store.selection = [clip.id]
2128 bladeAtPlayhead(at: quantize(secondsFor(p.x)),
2129 ids: [clip.id],
2130 rightBoard: { boards.duplicate($0) })
2131 return
2132 }
2133
2134 // Selection
2135 if event.modifierFlags.contains(.shift) {
2136 if store.selection.contains(clip.id) { store.selection.remove(clip.id) }
2137 else { store.selection.insert(clip.id) }
2138 } else if !store.selection.contains(clip.id) {
2139 store.selection = [clip.id]
2140 } else if store.selection.count > 1 {
2141 // Clicking an already-selected clip in a multi-selection keeps the
2142 // group intact so a drag can move it, but a click without a drag
2143 // collapses to just this clip (deselecting the rest) on mouseUp.
2144 drag.collapseTo = clip.id
2145 }
2146
2147 let rect = clipRect(clip, row: row)
2148 let edge: CGFloat = 7
2149
2150 // Audio fade handles beat edge trims.
2151 if clip.kind == .audio {
2152 let handleY = rect.minY + 13 + 4
2153 let xIn = xFor(clip.start + clip.fadeIn)
2154 let xOut = xFor(clip.end - clip.fadeOut)
2155 if abs(p.x - xIn) < 7, abs(p.y - handleY) < 9 {
2156 drag.mode = .fadeIn
2157 } else if abs(p.x - xOut) < 7, abs(p.y - handleY) < 9 {
2158 drag.mode = .fadeOut
2159 }
2160 }
2161
2162 if drag.mode == .none {
2163 let stretch = event.modifierFlags.contains(.command) && clip.kind == .video
2164 let opt = event.modifierFlags.contains(.option)
2165 let onIn = p.x - rect.minX < edge
2166 let onOut = rect.maxX - p.x < edge
2167 if opt, onOut {
2168 drag.mode = .rippleOut // ⌥-drag out edge: ripple the tail away
2169 } else if opt, onIn, clip.kind != .storyboard {
2170 drag.mode = .rippleIn // ⌥-drag in edge: ripple-trim the head
2171 } else if opt || session.mainTool == .slide {
2172 drag.mode = .slip // ⌥-drag body: slip source under the clip
2173 } else if onIn {
2174 drag.mode = stretch ? .stretchIn : .trimIn
2175 } else if onOut {
2176 drag.mode = stretch ? .stretchOut : .trimOut
2177 } else {
2178 drag.mode = .move
2179 }
2180 }
2181
2182 // Storyboard panels are start-only: clicking one parks the playhead on
2183 // it, and the body can't be dragged — only the edges (and nudges) move
2184 // the start. Everything else still works (blade, trims, ripple).
2185 if clip.kind == .storyboard {
2186 if drag.mode == .move || drag.mode == .slip {
2187 playback.setRate(0)
2188 playback.seek(to: quantize(clip.start))
2189 drag = DragState()
2190 needsDisplay = true
2191 return
2192 }
2193 }
2194
2195 drag.clipId = clip.id
2196 drag.origClip = clip
2197 // Moves and edge trims carry the whole selection + link-mates: dragging
2198 // one edge resizes every selected/linked clip by the same amount.
2199 // (Slip/stretch/fade read drag.origClip only, so a wider set is inert.)
2200 var editSet = project.expandLinks(store.selection)
2201 editSet.insert(clip.id)
2202 drag.origSelection = Dictionary(uniqueKeysWithValues:
2203 project.clips.filter { editSet.contains($0.id) }.map { ($0.id, $0) })
2204 store.beginGesture()
2205 }
2206
2207 override func mouseDragged(with event: NSEvent) {
2208 let p = convert(event.locationInWindow, from: nil)
2209 lastMousePoint = p
2210 let dSec = Double(p.x - drag.startPoint.x) / pxPerSecond
2211 activeSnapTarget = nil
2212 dragHintRow = nil
2213
2214 // Dragging against the view edges pans the timeline (there's no
2215 // enclosing scroll view, so the playhead could never leave the screen).
2216 if [.scrub, .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip,
2217 .stretchIn, .stretchOut, .fadeIn, .fadeOut].contains(drag.mode) {
2218 if p.x > bounds.width - 30 {
2219 originSecond += Double(p.x - (bounds.width - 30)) * 0.12 / pxPerSecond
2220 } else if p.x < headerW + 20 {
2221 originSecond -= Double(headerW + 20 - p.x) * 0.12 / pxPerSecond
2222 }
2223 originSecond = clampOrigin(originSecond)
2224 }
2225
2226 switch drag.mode {
2227 case .none: return
2228 case .scrub:
2229 playback.seek(to: max(0, quantize(secondsFor(p.x))))
2230 case .move: dragMove(p: p, dSec: dSec)
2231 case .trimIn: dragTrimIn(dSec: dSec)
2232 case .trimOut: dragTrimOut(dSec: dSec)
2233 case .rippleOut: dragRippleOut(dSec: dSec)
2234 case .rippleIn: dragRippleIn(dSec: dSec)
2235 case .slip: dragSlip(dSec: dSec)
2236 case .stretchIn: dragStretch(dSec: dSec, fromStart: true)
2237 case .stretchOut: dragStretch(dSec: dSec, fromStart: false)
2238 case .fadeIn, .fadeOut: dragFade(p: p)
2239 case .hBarPan:
2240 let span = barDrag.domainHi - barDrag.domainLo
2241 let d = Double(p.x - drag.startPoint.x) / Double(max(1, hBarRect.width)) * span
2242 originSecond = clampOrigin(barDrag.origOrigin + d)
2243 case .hBarLeft, .hBarRight:
2244 let bar = hBarRect
2245 let span = barDrag.domainHi - barDrag.domainLo
2246 let t = barDrag.domainLo + Double((p.x - bar.minX) / max(1, bar.width)) * span
2247 let viewW = Double(bounds.width - headerW)
2248 if drag.mode == .hBarLeft {
2249 let t1 = barDrag.origOrigin + viewW / barDrag.origPps
2250 let newT0 = min(max(t, barDrag.domainLo), t1 - viewW / 4000)
2251 pxPerSecond = min(max(viewW / (t1 - newT0), 0.05), 4000)
2252 originSecond = t1 - viewW / pxPerSecond
2253 } else {
2254 let t0 = barDrag.origOrigin
2255 let newT1 = max(min(t, barDrag.domainHi), t0 + viewW / 4000)
2256 pxPerSecond = min(max(viewW / (newT1 - t0), 0.05), 4000)
2257 originSecond = t0
2258 }
2259 case .vBarPan:
2260 let d = (p.y - drag.startPoint.y) / max(1, vBarRect.height) * barDrag.origContentH
2261 scrollY = min(max(0, barDrag.origScrollY + d), maxScrollY)
2262 case .vBarTop, .vBarBottom:
2263 let bar = vBarRect
2264 let viewH = max(1, bounds.height - lanesTop)
2265 var top = barDrag.thumb.minY, bottom = barDrag.thumb.maxY
2266 if drag.mode == .vBarTop { top = min(max(bar.minY, p.y), bottom - 18) }
2267 else { bottom = max(min(bar.maxY, p.y), top + 18) }
2268 let f = (bottom - top) / max(1, bar.height)
2269 let newContentH = viewH / max(0.05, f)
2270 let k = newContentH / max(1, barDrag.origContentH)
2271 session.laneScale = barDrag.origScale * k
2272 if drag.mode == .vBarTop {
2273 scrollY = min(max(0, (barDrag.origScrollY + viewH) * k - viewH), maxScrollY)
2274 } else {
2275 scrollY = min(max(0, barDrag.origScrollY * k), maxScrollY)
2276 }
2277 case .box:
2278 let r = boxRect()
2279 var hit = drag.baseSelection
2280 rebuildSceneIfNeeded()
2281 for (row, ref) in laneRows.enumerated() {
2282 for clip in clipsByLane[ref] ?? []
2283 where clipRect(clip, row: row).intersects(r) {
2284 hit.insert(clip.id)
2285 }
2286 }
2287 store.selection = hit
2288 case .resizeTrack:
2289 if let row = drag.resizeRow, let ref = laneRef(row: row) {
2290 let newH = drag.resizeOrigH + (p.y - drag.startPoint.y)
2291 let factor = newH / max(1, baseLaneH * session.laneScale)
2292 session.trackHeights[ref] = min(4, max(0.35, factor))
2293 }
2294 }
2295 drag.moved = true
2296 autoscroll(with: event)
2297 needsDisplay = true
2298 }
2299
2300 override func mouseUp(with event: NSEvent) {
2301 switch drag.mode {
2302 case .move, .trimIn, .trimOut, .rippleOut, .rippleIn, .slip,
2303 .stretchIn, .stretchOut, .fadeIn, .fadeOut:
2304 store.endGesture()
2305 default:
2306 break
2307 }
2308 // Click (no drag) on an already-selected clip collapses the multi-
2309 // selection down to just that clip.
2310 if !drag.moved, let id = drag.collapseTo { store.selection = [id] }
2311 drag = DragState()
2312 activeSnapTarget = nil
2313 dragHintRow = nil
2314 needsDisplay = true
2315 }
2316
2317 // MARK: - Middle-mouse pan
2318
2319 override func otherMouseDown(with event: NSEvent) {
2320 guard event.buttonNumber == 2 else { return }
2321 window?.makeFirstResponder(self)
2322 let p = convert(event.locationInWindow, from: nil)
2323 panDrag = PanDrag(active: true, start: p,
2324 origOrigin: originSecond, origScrollY: scrollY)
2325 }
2326
2327 override func otherMouseDragged(with event: NSEvent) {
2328 guard panDrag.active else { return }
2329 let p = convert(event.locationInWindow, from: nil)
2330 originSecond = clampOrigin(panDrag.origOrigin
2331 - Double(p.x - panDrag.start.x) / pxPerSecond)
2332 if maxScrollY > 0 {
2333 scrollY = min(max(0, panDrag.origScrollY - (p.y - panDrag.start.y)), maxScrollY)
2334 }
2335 needsDisplay = true
2336 }
2337
2338 override func otherMouseUp(with event: NSEvent) {
2339 guard event.buttonNumber == 2 else { return }
2340 panDrag.active = false
2341 }
2342
2343 private func dragMove(p: NSPoint, dSec: Double) {
2344 guard let orig = drag.origClip else { return }
2345 // Vertical retracking only for a lone unlinked clip.
2346 let multi = drag.origSelection.count > 1 || store.selection.count > 1
2347
2348 // Snap+quantize the moved start edge, then clamp to t >= 0 for all moved clips.
2349 let snappedStart = snapAndQuantize(orig.start + dSec,
2350 excluding: Set(drag.origSelection.keys),
2351 duration: orig.duration)
2352 var delta = snappedStart - orig.start
2353 let minStart = drag.origSelection.values.map(\.start).min() ?? 0
2354 if minStart + delta < 0 { delta = -minStart }
2355
2356 // Vertical: retarget track of the grabbed clip only (single-clip
2357 // drags). Rows resolve against the GESTURE BASE — the closure below
2358 // rebuilds from it, so live-project indices (which may include ghost
2359 // tracks added by an earlier update in this same drag) would be off.
2360 let baseModel = store.gestureBaseModel ?? project
2361 let isPanel = orig.kind == .storyboard
2362 var targetRef: TrackRef? = nil
2363 var neededNewTracks = 0
2364 var groupRowDelta = 0
2365 if let row = rowAt(y: p.y) {
2366 let rows = baseModel.laneRefs
2367 let baseCount = rows.count
2368 if !multi {
2369 if row >= baseCount {
2370 // Dragging N rows below the last lane creates N tracks at
2371 // once; the clip lands on the deepest one. Storyboard panels
2372 // stay on THE storyboard track.
2373 if !isPanel {
2374 neededNewTracks = min(8, row - baseCount + 1)
2375 dragHintRow = row
2376 }
2377 } else {
2378 // Storyboard panels are isolated to the storyboard lane and
2379 // other clips stay off it.
2380 let target = rows[row]
2381 if (target == .storyboard) == isPanel {
2382 targetRef = target
2383 }
2384 }
2385 } else if row < baseCount, let origRow = baseModel.row(of: orig.track) {
2386 // Vertical GROUP move: the whole selection (link groups
2387 // included) shifts by the same number of rows, when every
2388 // destination row exists and is a plain video track.
2389 let rowDelta = row - origRow
2390 if rowDelta != 0 {
2391 let ok = drag.origSelection.values.allSatisfy { c in
2392 guard c.kind != .storyboard,
2393 let r = baseModel.row(of: c.track),
2394 r + rowDelta >= 0, r + rowDelta < baseCount
2395 else { return false }
2396 return rows[r + rowDelta].videoIndex != nil
2397 }
2398 if ok { groupRowDelta = rowDelta }
2399 }
2400 }
2401 }
2402 if ProcessInfo.processInfo.environment["SEQ_DEBUG"] != nil {
2403 FileHandle.standardError.write(Data(
2404 ("dragMove p=\(p) row=\(rowAt(y: p.y).map(String.init) ?? "nil") " +
2405 "tracks=\(project.tracks.count) target=\(targetRef.map(\.wire) ?? "nil") " +
2406 "new=\(neededNewTracks) delta=\(delta)\n").utf8))
2407 }
2408
2409 store.updateGesture { model in
2410 for (id, o) in drag.origSelection {
2411 guard let i = model.clips.firstIndex(where: { $0.id == id }) else { continue }
2412 model.clips[i].start = o.start + delta
2413 }
2414 if groupRowDelta != 0 {
2415 let rows = model.laneRefs
2416 for (id, o) in drag.origSelection {
2417 guard let i = model.clips.firstIndex(where: { $0.id == id }),
2418 let r = model.row(of: o.track)
2419 else { continue }
2420 let nr = r + groupRowDelta
2421 if nr >= 0, nr < rows.count {
2422 model.clips[i].track = rows[nr]
2423 }
2424 }
2425 }
2426 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
2427 if neededNewTracks > 0 {
2428 // Indices are deterministic, so appending N lanes needs no
2429 // stable identities — the clip lands on the deepest new one.
2430 for _ in 0..<neededNewTracks { model.addTrack() }
2431 model.clips[i].track = .video(model.tracks.count - 1)
2432 } else if let ref = targetRef {
2433 model.clips[i].track = ref
2434 }
2435 }
2436 }
2437
2438 /// A trim/slip drag is exposing source material the clip didn't use
2439 /// before — tell the proxy builder NOW (mid-drag), not at mouse-up, so
2440 /// the newly extended range is often already covered when the user plays
2441 /// it. `direction` is which way the exposure grows in source time.
2442 private func prefetchExposure(_ orig: Clip, sourceTime: Double, direction: Int) {
2443 guard orig.kind == .video, let media = project.media(orig.mediaId) else { return }
2444 ctx.chunks.noteGestureExposure(media: media, sourceTime: sourceTime,
2445 direction: direction)
2446 }
2447
2448 /// Non-audio clips on the same track whose head would be swallowed by
2449 /// dragging `orig`'s out-edge to `newEnd` (audio layers freely, so it
2450 /// never gets pushed).
2451 private func pushVictimsOut(orig: Clip, newEnd: Double, model: ProjectModel) -> [Clip] {
2452 model.clips.filter {
2453 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio
2454 && $0.start >= orig.start && $0.start < newEnd && $0.end > orig.end - 1e-9
2455 }
2456 }
2457
2458 private func dragTrimOut(dSec: Double) {
2459 guard let orig = drag.origClip else { return }
2460 // Storyboard panels are start-only: a panel "ends" where the next one
2461 // starts, so dragging the out edge really drags the NEXT panel's start.
2462 if orig.kind == .storyboard {
2463 let base = store.gestureBaseModel ?? project
2464 let panels = base.clips(on: orig.track).filter { $0.kind == .storyboard }
2465 guard let idx = panels.firstIndex(where: { $0.id == orig.id }),
2466 idx + 1 < panels.count else { return } // last panel: open-ended
2467 let next = panels[idx + 1]
2468 var desired = quantize(next.start + dSec)
2469 desired = max(desired, orig.start + frameDur)
2470 if idx + 2 < panels.count {
2471 desired = min(desired, panels[idx + 2].start - frameDur)
2472 }
2473 store.updateGesture { model in
2474 guard let i = model.clips.firstIndex(where: { $0.id == next.id }) else { return }
2475 model.clips[i].start = desired
2476 }
2477 return
2478 }
2479 let media = project.media(orig.mediaId)
2480 let desired = snapAndQuantize(orig.end + dSec, excluding: [orig.id])
2481 var maxEnd = Double.greatestFiniteMagnitude
2482 if let media {
2483 maxEnd = orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed)
2484 }
2485 let base = store.gestureBaseModel ?? project
2486 var newEnd = min(max(desired, orig.start + frameDur), maxEnd)
2487 // Trimming through a neighbor auto-trims the neighbor's head, but a
2488 // clip can never be swallowed past its last frame.
2489 let victims = pushVictimsOut(orig: orig, newEnd: newEnd, model: base)
2490 for v in victims { newEnd = min(newEnd, v.end - frameDur) }
2491 newEnd = max(newEnd, orig.start + frameDur)
2492 // The grabbed clip's clamped change is the delta applied to every
2493 // selected/linked clip.
2494 let delta = newEnd - orig.end
2495 if delta > 0 { // extending the tail exposes fresh source material
2496 prefetchExposure(orig, sourceTime: orig.srcIn + (newEnd - orig.start) * orig.speed,
2497 direction: 1)
2498 }
2499 let targets = drag.origSelection.values.filter { $0.kind != .storyboard }
2500 let single = targets.count <= 1
2501 store.updateGesture { model in
2502 for t in targets {
2503 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
2504 var end = t.end + delta
2505 if let m = model.media(t.mediaId) {
2506 end = min(end, t.start + (m.duration - t.srcIn) / max(0.001, t.speed))
2507 }
2508 end = max(end, t.start + self.frameDur)
2509 model.clips[i].duration = end - t.start
2510 guard single else { continue } // neighbor auto-trim: single clip only
2511 for v in self.pushVictimsOut(orig: t, newEnd: end, model: model)
2512 where v.start < end {
2513 guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue }
2514 let shift = end - v.start
2515 model.clips[j].srcIn = v.srcIn + shift * v.speed
2516 model.clips[j].start = end
2517 model.clips[j].duration = v.duration - shift
2518 }
2519 }
2520 }
2521 }
2522
2523 private func dragTrimIn(dSec: Double) {
2524 guard let orig = drag.origClip else { return }
2525 let desired = snapAndQuantize(orig.start + dSec, excluding: [orig.id])
2526 // Storyboard panels are pure start positions (no source media), so the
2527 // in-edge just slides the panel's start either way — bounded only by the
2528 // previous panel (a frame of clearance) and this panel's own end.
2529 if orig.kind == .storyboard {
2530 let base = store.gestureBaseModel ?? project
2531 let panels = base.clips(on: orig.track)
2532 .filter { $0.kind == .storyboard }
2533 .sorted { $0.start < $1.start }
2534 guard let idx = panels.firstIndex(where: { $0.id == orig.id }) else { return }
2535 let lower = idx > 0 ? panels[idx - 1].start + frameDur : 0
2536 let newStart = min(max(desired, lower), orig.end - frameDur)
2537 store.updateGesture { model in
2538 guard let i = model.clips.firstIndex(where: { $0.id == orig.id }) else { return }
2539 model.clips[i].start = newStart
2540 model.clips[i].srcIn = 0
2541 model.clips[i].duration = orig.end - newStart
2542 }
2543 return
2544 }
2545 var minStart = orig.start - orig.srcIn / max(0.001, orig.speed)
2546 minStart = max(0, minStart)
2547 let maxStart = orig.end - frameDur
2548 let newStart = min(max(desired, minStart), maxStart)
2549 if newStart < orig.start { // extending the head exposes earlier source
2550 prefetchExposure(orig, sourceTime: orig.srcIn + (newStart - orig.start) * orig.speed,
2551 direction: -1)
2552 }
2553 let base = store.gestureBaseModel ?? project
2554 // The grabbed clip's clamped change is the delta applied to every
2555 // selected/linked clip. A storyboard in-edge just moves that panel's
2556 // start (normalization re-derives its duration).
2557 let delta = newStart - orig.start
2558 let targets = Array(drag.origSelection.values.filter { $0.kind != .storyboard })
2559 let single = targets.count <= 1
2560 // Trimming back through the previous clip trims its tail (single only).
2561 let victims = base.clips.filter {
2562 $0.id != orig.id && $0.track == orig.track && $0.kind != .audio
2563 && $0.start < orig.start && $0.end > newStart
2564 }
2565 store.updateGesture { model in
2566 for t in targets {
2567 guard let i = model.clips.firstIndex(where: { $0.id == t.id }) else { continue }
2568 let tMin = max(0, t.start - t.srcIn / max(0.001, t.speed))
2569 let s = min(max(t.start + delta, tMin), t.end - self.frameDur)
2570 model.clips[i].srcIn = t.srcIn + (s - t.start) * t.speed
2571 model.clips[i].start = s
2572 model.clips[i].duration = t.end - s
2573 }
2574 guard single else { return }
2575 for v in victims where v.end > newStart {
2576 guard let j = model.clips.firstIndex(where: { $0.id == v.id }) else { continue }
2577 model.clips[j].duration = max(self.frameDur, newStart - v.start)
2578 }
2579 }
2580 }
2581
2582 /// ⌥-drag a clip's out edge: ripple resize. The edge trims/extends like a
2583 /// normal trim, and everything after it on the same track shifts by the
2584 /// same amount ("pushing the rest away"). On the storyboard track this
2585 /// resizes a panel's slot while keeping the later panels' spacing.
2586 private func dragRippleOut(dSec: Double) {
2587 guard let orig = drag.origClip else { return }
2588 let base = store.gestureBaseModel ?? project
2589 let desired = snapAndQuantize(orig.end + dSec, excluding: [orig.id])
2590 var newEnd = max(desired, orig.start + frameDur)
2591 if orig.kind == .video, let media = base.media(orig.mediaId) {
2592 newEnd = min(newEnd, orig.start + (media.duration - orig.srcIn) / max(0.001, orig.speed))
2593 }
2594 let delta = newEnd - orig.end
2595 let followers = base.clips.filter {
2596 $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9
2597 }
2598 // Never push anything below t = 0.
2599 let minStart = followers.map(\.start).min() ?? 0
2600 let clampedDelta = max(delta, -minStart)
2601 if clampedDelta > 0 { // ripple-extending the tail exposes fresh source
2602 prefetchExposure(orig,
2603 sourceTime: orig.srcIn + (orig.end + clampedDelta - orig.start) * orig.speed,
2604 direction: 1)
2605 }
2606 store.updateGesture { model in
2607 if orig.kind != .storyboard,
2608 let i = model.clips.firstIndex(where: { $0.id == orig.id }) {
2609 model.clips[i].duration = (orig.end + clampedDelta) - orig.start
2610 }
2611 for f in followers {
2612 guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue }
2613 model.clips[j].start = f.start + clampedDelta
2614 }
2615 }
2616 }
2617
2618 /// ⌥-drag a clip's IN edge: ripple-trim the head while pinning the clip's
2619 /// start. The in-point (`srcIn`) and length change —
2620 /// the clip's OUT edge moves — and every later clip on the track shifts by the
2621 /// same amount so the timeline stays gapless. Unlike a normal trim-in (which
2622 /// slides the clip's start and opens blank space *before* it), the start stays
2623 /// put and the space is taken out of the track. Dragging right trims the head
2624 /// (clip shrinks, followers pull in); left extends it (clip grows, followers
2625 /// push out). The out edge — the source out-point — never moves.
2626 private func dragRippleIn(dSec: Double) {
2627 guard let orig = drag.origClip, orig.kind != .storyboard else { return }
2628 let base = store.gestureBaseModel ?? project
2629 let followers = base.clips.filter {
2630 $0.id != orig.id && $0.track == orig.track && $0.start >= orig.end - 1e-9
2631 }
2632 // Snap the moving OUT edge to static references only — the followers ride
2633 // along with it, so they can't be snap targets. d > 0 trims the head.
2634 var exclude = Set(followers.map(\.id)); exclude.insert(orig.id)
2635 let newEnd = snapAndQuantize(orig.end - dSec, excluding: exclude)
2636 var d = orig.end - newEnd
2637 d = min(d, orig.duration - frameDur) // keep ≥ one frame
2638 d = max(d, -orig.srcIn / max(0.001, orig.speed)) // head ≥ source start
2639 if let firstStart = followers.map(\.start).min() { // followers ≥ t = 0
2640 d = min(d, firstStart)
2641 }
2642 if d < 0 { // ripple-extending the head exposes earlier source
2643 prefetchExposure(orig, sourceTime: orig.srcIn + d * orig.speed, direction: -1)
2644 }
2645 store.updateGesture { model in
2646 guard let i = model.clips.firstIndex(where: { $0.id == orig.id }) else { return }
2647 model.clips[i].srcIn = orig.srcIn + d * orig.speed
2648 model.clips[i].duration = orig.duration - d
2649 for f in followers {
2650 guard let j = model.clips.firstIndex(where: { $0.id == f.id }) else { continue }
2651 model.clips[j].start = f.start - d
2652 }
2653 }
2654 }
2655
2656 private func dragSlip(dSec: Double) {
2657 guard let orig = drag.origClip, let media = project.media(orig.mediaId) else { return }
2658 let maxIn = max(0, media.duration - orig.sourceLength)
2659 let newIn = min(max(orig.srcIn - dSec * orig.speed, 0), maxIn)
2660 // Slipping reveals source on the side the content is sliding from.
2661 if newIn < orig.srcIn {
2662 prefetchExposure(orig, sourceTime: newIn, direction: -1)
2663 } else if newIn > orig.srcIn {
2664 prefetchExposure(orig, sourceTime: newIn + orig.sourceLength, direction: 1)
2665 }
2666 store.updateGesture { model in
2667 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
2668 model.clips[i].srcIn = newIn
2669 }
2670 }
2671
2672 /// Reaper-style time stretch: ⌘-drag a clip edge. The source range stays
2673 /// fixed; duration changes and speed compensates.
2674 private func dragStretch(dSec: Double, fromStart: Bool) {
2675 guard let orig = drag.origClip else { return }
2676 let srcLen = orig.sourceLength
2677 var newDur: Double
2678 var newStart = orig.start
2679 if fromStart {
2680 var desired = quantize(orig.start + dSec)
2681 desired = min(max(desired, 0), orig.end - frameDur)
2682 newStart = desired
2683 newDur = orig.end - desired
2684 } else {
2685 let desired = quantize(orig.end + dSec)
2686 newDur = max(frameDur, desired - orig.start)
2687 }
2688 newDur = min(max(newDur, srcLen / 50), srcLen * 50)
2689 if fromStart { newStart = orig.end - newDur }
2690 let speed = srcLen / newDur
2691 store.updateGesture { model in
2692 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
2693 model.clips[i].start = newStart
2694 model.clips[i].duration = newDur
2695 model.clips[i].speed = speed
2696 }
2697 }
2698
2699 private func dragFade(p: NSPoint) {
2700 guard let orig = drag.origClip else { return }
2701 let sec = secondsFor(p.x)
2702 store.updateGesture { model in
2703 guard let i = model.clips.firstIndex(where: { $0.id == drag.clipId }) else { return }
2704 if drag.mode == .fadeIn {
2705 model.clips[i].fadeIn = min(max(sec - orig.start, 0), orig.duration)
2706 } else {
2707 model.clips[i].fadeOut = min(max(orig.end - sec, 0), orig.duration)
2708 }
2709 }
2710 }
2711
2712 /// The single place edge alignment is decided: snap `desired` to nearby clip
2713 /// edges / playhead / 0 (unless snapping is off), record the snap indicator,
2714 /// then frame-quantize — snap FIRST, quantize ONCE. Every drag handler routes
2715 /// through here so trims, moves and ripples align identically (previously each
2716 /// re-implemented this, and the ripple handler quantized twice out of order).
2717 private func snapAndQuantize(_ desired: Double, excluding: Set<UUID>,
2718 duration: Double = 0) -> Double {
2719 var v = desired
2720 if let adj = snapAdjust(start: v, duration: duration, excluding: excluding) {
2721 v += adj.adjust
2722 activeSnapTarget = adj.target
2723 }
2724 return quantize(v)
2725 }
2726
2727 private func snapAdjust(start: Double, duration: Double, excluding: Set<UUID>)
2728 -> (adjust: Double, target: Double)? {
2729 // Holding ⇧ mid-drag temporarily inverts snapping.
2730 let inverted = NSEvent.modifierFlags.contains(.shift)
2731 guard session.snapping != inverted else { return nil }
2732 let threshold = 8.0 / pxPerSecond
2733 var targets: [Double] = [0, playback.playhead]
2734 for c in project.clips where !excluding.contains(c.id) {
2735 targets.append(c.start)
2736 targets.append(c.end)
2737 }
2738 var best: (adjust: Double, target: Double)?
2739 let edges = duration > 0 ? [start, start + duration] : [start]
2740 for t in targets {
2741 for e in edges {
2742 let adj = t - e
2743 if abs(adj) < threshold, best == nil || abs(adj) < abs(best!.adjust) {
2744 best = (adj, t)
2745 }
2746 }
2747 }
2748 return best
2749 }
2750
2751 // MARK: - Cursor & mouse tracking
2752
2753 override func updateTrackingAreas() {
2754 super.updateTrackingAreas()
2755 trackingAreas.forEach(removeTrackingArea)
2756 addTrackingArea(NSTrackingArea(
2757 rect: bounds, options: [.mouseMoved, .activeInKeyWindow, .inVisibleRect],
2758 owner: self, userInfo: nil))
2759 }
2760
2761 override func mouseMoved(with event: NSEvent) {
2762 lastMousePoint = convert(event.locationInWindow, from: nil)
2763 cursor(for: event.modifierFlags).set()
2764 }
2765
2766 // Holding a modifier changes what a drag would do, so refresh the cursor even
2767 // when the pointer is still (mouseMoved won't fire on a bare key press).
2768 override func flagsChanged(with event: NSEvent) {
2769 cursor(for: event.modifierFlags).set()
2770 }
2771
2772 /// The cursor for the pointer's current spot, given the held modifiers, so it
2773 /// previews the gesture a drag would start: ⌥ over a clip body slips it, ⌥/⌘
2774 /// or a plain hover over an edge resizes (trim / ripple / stretch).
2775 private func cursor(for mods: NSEvent.ModifierFlags) -> NSCursor {
2776 let p = lastMousePoint
2777 if let barMode = scrollbarHit(p) {
2778 switch barMode {
2779 case .hBarLeft, .hBarRight: return .resizeLeftRight
2780 case .vBarTop, .vBarBottom: return .resizeUpDown
2781 default: return .arrow
2782 }
2783 }
2784 if trackBoundaryAt(y: p.y) != nil, p.y > lanesTop { return .resizeUpDown }
2785 guard let (clip, row) = clipAt(point: p) else { return .arrow }
2786 if session.mainTool == .blade { return .crosshair }
2787 if session.mainTool == .slide { return .openHand }
2788 let rect = clipRect(clip, row: row)
2789 let onEdge = p.x - rect.minX < 7 || rect.maxX - p.x < 7
2790 if mods.contains(.option), !onEdge { return .openHand } // ⌥ body: slip
2791 return onEdge ? .resizeLeftRight : .arrow
2792 }
2793
2794 // MARK: - Keyboard (fallbacks; the menu bar owns the canonical bindings)
2795
2796 override func keyDown(with event: NSEvent) {
2797 let pc = playback
2798 // A key pressed WHILE a mouse drag holds an open gesture must not run —
2799 // a mutating shortcut (split/delete/nudge) calls `store.mutate`, whose
2800 // `precondition(gestureBase == nil)` would trap mid-drag. Ignore keys
2801 // until the drag ends; Escape still cancels it.
2802 if store.gestureBaseModel != nil {
2803 if event.keyCode == 53 { cancelOperation(nil) } // Esc → cancel drag
2804 return
2805 }
2806 switch event.charactersIgnoringModifiers?.lowercased() {
2807 case " ": pc.togglePlay()
2808 case "j": pc.shuttle(-1)
2809 case "k": pc.setRate(0)
2810 case "l": pc.shuttle(1)
2811 case "s": split()
2812 case "n": toggleNewShot()
2813 case "i": pc.setIn()
2814 case "o":
2815 if event.modifierFlags.contains(.option) { moveOverlapsToSeparateTracks() }
2816 else { pc.setOut() }
2817 case "c": pc.toggleLoop()
2818 case "m":
2819 if event.modifierFlags.contains(.option) { toggleMute() }
2820 else { toggleMarkerAtPlayhead() }
2821 case "v": session.mainTool = .select
2822 case "y": session.snapping.toggle()
2823 case "g":
2824 if event.modifierFlags.contains(.option) { unlinkSelection() }
2825 else { linkSelection() }
2826 case "b": splitStoryboardAtPlayhead(newShot: event.modifierFlags.contains(.shift))
2827 case "x": // X delete / ⇧X ripple (same as ⌫)
2828 if store.selection.isEmpty {
2829 closeBlankSpace(at: quantize(pc.playhead))
2830 } else if event.modifierFlags.contains(.shift) {
2831 rippleDelete()
2832 } else {
2833 deleteSelection()
2834 }
2835 case "\\": rippleDelete() // documented default ripple-delete key (also ⌥⌫)
2836 case "a" where !event.modifierFlags.contains(.command): // ripple trim left (also ⌥←)
2837 rippleTrimToPlayhead(deleteLeft: true)
2838 case "d" where !event.modifierFlags.contains(.command): // ripple trim right (also ⌥→)
2839 rippleTrimToPlayhead(deleteLeft: false)
2840 case "[":
2841 if event.modifierFlags.contains(.option) { goToPrevMarker() }
2842 else if event.modifierFlags.contains(.command) { goToPrevStoryboardPanel() }
2843 else { pc.step(by: -frameDur) }
2844 case "]":
2845 if event.modifierFlags.contains(.option) { goToNextMarker() }
2846 else if event.modifierFlags.contains(.command) { goToNextStoryboardPanel() }
2847 else { pc.step(by: frameDur) }
2848 default:
2849 switch event.keyCode {
2850 case 123 where event.modifierFlags.contains(.option): // ⌥←
2851 rippleTrimToPlayhead(deleteLeft: true)
2852 case 124 where event.modifierFlags.contains(.option): // ⌥→
2853 rippleTrimToPlayhead(deleteLeft: false)
2854 case 123: nudgeSelection(by: event.modifierFlags.contains(.shift) ? -1 : -frameDur) // ←
2855 case 124: nudgeSelection(by: event.modifierFlags.contains(.shift) ? 1 : frameDur) // →
2856 case 53: cancelOperation(nil) // esc
2857 case 115: pc.seek(to: 0) // Home
2858 case 119: pc.seek(to: project.timelineDuration) // End
2859 case 51, 117: // ⌫, ⌦
2860 if store.selection.isEmpty {
2861 closeBlankSpace(at: quantize(pc.playhead)) // "delete the space"
2862 } else if event.modifierFlags.contains(.option) {
2863 rippleDelete()
2864 } else {
2865 deleteSelection()
2866 }
2867 default: super.keyDown(with: event)
2868 }
2869 }
2870 }
2871
2872 /// ←/→ nudge the selected clips by a frame (⇧ = 1 s); with nothing
2873 /// selected they move the playhead like [ and ].
2874 func nudgeSelection(by seconds: Double) {
2875 let sel = project.expandLinks(store.selection)
2876 guard !sel.isEmpty else {
2877 playback.step(by: seconds)
2878 return
2879 }
2880 store.mutate { model in
2881 let minStart = model.clips.filter { sel.contains($0.id) }.map(\.start).min() ?? 0
2882 let d = max(seconds, -minStart)
2883 for i in model.clips.indices where sel.contains(model.clips[i].id) {
2884 model.clips[i].start += d
2885 }
2886 }
2887 }
2888
2889 // MARK: - Clip actions
2890
2891 func toggleMute() {
2892 var ids = store.selection
2893 if ids.isEmpty, let (clip, _) = clipAt(point: lastMousePoint) { ids = [clip.id] }
2894 guard !ids.isEmpty else { return }
2895 store.mutate { model in
2896 let allMuted = model.clips.filter { ids.contains($0.id) }.allSatisfy(\.muted)
2897 for i in model.clips.indices where ids.contains(model.clips[i].id) {
2898 model.clips[i].muted = !allMuted
2899 }
2900 }
2901 }
2902
2903 /// S — Reaper-style split at the playhead. When the playhead sits INSIDE
2904 /// a red overlap involving the selection (or any overlap when nothing is
2905 /// selected), the split resolves it: the earlier clip's out point and the
2906 /// later clip's in point meet at the playhead. Anywhere else it's a
2907 /// normal split (overlap left alone). Storyboard panels split by
2908 /// duplicating the drawing (the split time becomes the new panel's time).
2909 func split() {
2910 let t = quantize(playback.playhead)
2911 let sel = store.selection
2912 let atPlayhead = project.overlaps().filter { o in
2913 t > o.start + 1e-9 && t < o.end - 1e-9
2914 && (sel.isEmpty || sel.contains(o.a.id) || sel.contains(o.b.id))
2915 }
2916 if !atPlayhead.isEmpty {
2917 let fd = frameDur
2918 store.mutate { model in
2919 for o in atPlayhead {
2920 if let i = model.clips.firstIndex(where: { $0.id == o.a.id }) {
2921 model.clips[i].duration = max(fd, t - model.clips[i].start)
2922 }
2923 if let i = model.clips.firstIndex(where: { $0.id == o.b.id }) {
2924 let shift = t - model.clips[i].start
2925 if shift > 0, shift < model.clips[i].duration - fd / 2 {
2926 model.clips[i].srcIn += shift * model.clips[i].speed
2927 model.clips[i].start = t
2928 model.clips[i].duration -= shift
2929 }
2930 }
2931 }
2932 }
2933 NotificationCenter.default.post(name: .transientStatus, object: nil,
2934 userInfo: ["text": "Split resolved \(atPlayhead.count) overlap\(atPlayhead.count == 1 ? "" : "s") at the playhead"])
2935 return
2936 }
2937 bladeAtPlayhead(rightBoard: { board in boards.duplicate(board) })
2938 }
2939
2940 /// B — split the storyboard panel under the playhead into two, regardless
2941 /// of the selection; the new (right) panel inherits the drawing and becomes
2942 /// the selection. ⇧B (newShot) also flags that new panel as a new shot.
2943 /// With no panel under the playhead there's nothing to cut, so a fresh one
2944 /// is dropped there instead — so B always advances the storyboard.
2945 func splitStoryboardAtPlayhead(newShot: Bool = false) {
2946 let t = quantize(playback.playhead)
2947 guard let panel = project.clipAt(track: .storyboard, time: t, kind: .storyboard),
2948 t > panel.start + frameDur / 2, t < panel.end - frameDur / 2
2949 else {
2950 addStoryboardPanel(at: t)
2951 if newShot { toggleNewShot() }
2952 return
2953 }
2954 let rightBoard = panel.board.map { boards.duplicate($0) }
2955 ?? project.newBoard()
2956 var newId: UUID?
2957 store.mutate { model in
2958 guard let i = model.clips.firstIndex(where: { $0.id == panel.id }) else { return }
2959 var right = panel
2960 right.id = UUID()
2961 right.start = t
2962 right.srcIn = 0
2963 right.duration = panel.end - t
2964 right.board = rightBoard
2965 right.newShot = newShot
2966 model.clips[i].duration = t - panel.start
2967 model.clips.append(right)
2968 newId = right.id
2969 }
2970 if let id = newId { store.selection = [id] }
2971 }
2972
2973 /// N — toggle the "new shot" marker on the targeted storyboard panel(s):
2974 /// the selection if it holds any panels, else the panel under the playhead.
2975 func toggleNewShot() {
2976 let ids = newShotTargets()
2977 guard !ids.isEmpty else { return }
2978 store.mutate { model in
2979 // If any target isn't a new shot yet, turn them all on; else clear.
2980 let turnOn = ids.contains { id in
2981 model.clips.first(where: { $0.id == id })?.newShot == false
2982 }
2983 for i in model.clips.indices where ids.contains(model.clips[i].id) {
2984 model.clips[i].newShot = turnOn
2985 }
2986 }
2987 }
2988
2989 /// Storyboard panels the new-shot toggle acts on: selected panels, or —
2990 /// when nothing storyboard is selected — the panel under the playhead.
2991 func newShotTargets() -> Set<UUID> {
2992 var ids = store.selection.filter { project.clip($0)?.kind == .storyboard }
2993 if ids.isEmpty,
2994 let panel = project.clipAt(track: .storyboard,
2995 time: playback.playhead,
2996 kind: .storyboard) {
2997 ids = [panel.id]
2998 }
2999 return ids
3000 }
3001
3002 /// For the menu checkbox: nil = no storyboard panel is targeted; otherwise
3003 /// whether every targeted panel is already flagged a new shot.
3004 var newShotMenuState: Bool? {
3005 let ids = newShotTargets()
3006 guard !ids.isEmpty else { return nil }
3007 return ids.allSatisfy { project.clip($0)?.newShot == true }
3008 }
3009
3010 func bladeAtPlayhead(at time: Double? = nil, ids explicitIds: Set<UUID>? = nil,
3011 onlyStoryboards: Bool = false, newShot: Bool = false,
3012 rightBoard: (Board) -> Board) {
3013 let t = time ?? quantize(playback.playhead)
3014 var ids = explicitIds ?? store.selection
3015 // With nothing selected, S blades everything the playhead crosses — but
3016 // NOT storyboard panels (those only split via an explicit selection or
3017 // the N key, which passes onlyStoryboards).
3018 if ids.isEmpty {
3019 ids = Set(project.clips
3020 .filter { onlyStoryboards || $0.kind != .storyboard }
3021 .map(\.id))
3022 }
3023 ids = project.expandLinks(ids)
3024 let victims = project.clips.filter {
3025 ids.contains($0.id) && t > $0.start + frameDur / 2 && t < $0.end - frameDur / 2
3026 && (!onlyStoryboards || $0.kind == .storyboard)
3027 }
3028 guard !victims.isEmpty else { return }
3029 // When the split acted on an actual selection, both resulting halves
3030 // stay selected. (The blade tool passes explicitIds and leaves the
3031 // selection alone.)
3032 let reselect = explicitIds == nil && !store.selection.isEmpty
3033 var newRightIds: [UUID] = []
3034 var rightLinkIds: [UUID: UUID] = [:] // old linkId → new right-half linkId
3035 // Board copies happen OUTSIDE mutate (they touch disk).
3036 var rightBoards: [UUID: Board] = [:]
3037 for v in victims where v.kind == .storyboard {
3038 if let board = v.board { rightBoards[v.id] = rightBoard(board) }
3039 }
3040 store.mutate { model in
3041 for v in victims {
3042 guard let i = model.clips.firstIndex(where: { $0.id == v.id }) else { continue }
3043 var right = v
3044 right.id = UUID()
3045 newRightIds.append(right.id)
3046 right.start = t
3047 right.srcIn = v.srcIn + (t - v.start) * v.speed
3048 right.duration = v.end - t
3049 if let link = v.linkId {
3050 if rightLinkIds[link] == nil { rightLinkIds[link] = UUID() }
3051 right.linkId = rightLinkIds[link]
3052 }
3053 if v.kind == .storyboard {
3054 right.srcIn = 0
3055 right.board = rightBoards[v.id]
3056 right.newShot = newShot
3057 }
3058 // Fades stay on their outer edges; the cut itself is clean.
3059 if v.kind == .audio {
3060 right.fadeIn = 0
3061 right.fadeOut = min(v.fadeOut, right.duration)
3062 }
3063 model.clips[i].duration = t - v.start
3064 if v.kind == .audio {
3065 model.clips[i].fadeOut = 0
3066 model.clips[i].fadeIn = min(v.fadeIn, t - v.start)
3067 }
3068 model.clips.append(right)
3069 }
3070 }
3071 if reselect {
3072 store.selection = Set(victims.map(\.id)).union(newRightIds)
3073 }
3074 }
3075
3076 /// O — move overlapping clips apart: the later clip of each overlap goes
3077 /// to another track with room (that's what separate tracks are for),
3078 /// creating one when needed. Splitting at the playhead (S) is the other
3079 /// way to resolve.
3080 func moveOverlapsToSeparateTracks() {
3081 let before = project.overlaps().count
3082 guard before > 0 else {
3083 NotificationCenter.default.post(name: .transientStatus, object: nil,
3084 userInfo: ["text": "No overlaps to move"])
3085 return
3086 }
3087 store.mutate { model in
3088 var guardCount = 0
3089 while guardCount < 100 {
3090 guardCount += 1
3091 guard let o = model.overlaps().first,
3092 let bi = model.clips.firstIndex(where: { $0.id == o.b.id })
3093 else { break }
3094 let b = model.clips[bi]
3095 if b.kind == .storyboard { // panels can't truly overlap; park on the lane
3096 model.clips[bi].track = .storyboard
3097 continue
3098 }
3099 // A video lane with no conflicting clip, else a fresh one.
3100 let targetIndex = model.tracks.indices.first(where: { idx in
3101 TrackRef.video(idx) != b.track
3102 && !model.clips.contains {
3103 $0.id != b.id && $0.track == .video(idx) && $0.kind != .audio
3104 && $0.start < b.end && $0.end > b.start
3105 }
3106 }) ?? model.addTrack()
3107 model.clips[bi].track = .video(targetIndex)
3108 }
3109 }
3110 NotificationCenter.default.post(name: .transientStatus, object: nil,
3111 userInfo: ["text": "Moved \(before) overlap\(before == 1 ? "" : "s") to separate tracks"])
3112 }
3113
3114 func linkSelection() {
3115 let ids = store.selection
3116 guard ids.count > 1 else { return }
3117 let link = UUID()
3118 store.mutate { model in
3119 for i in model.clips.indices where ids.contains(model.clips[i].id) {
3120 model.clips[i].linkId = link
3121 }
3122 }
3123 NotificationCenter.default.post(name: .transientStatus, object: nil,
3124 userInfo: ["text": "Linked \(ids.count) clips"])
3125 }
3126
3127 func unlinkSelection() {
3128 let ids = project.expandLinks(store.selection)
3129 guard !ids.isEmpty else { return }
3130 store.mutate { model in
3131 for i in model.clips.indices where ids.contains(model.clips[i].id) {
3132 model.clips[i].linkId = nil
3133 }
3134 }
3135 }
3136
3137 func deleteSelection() {
3138 // Deleting a linked clip deletes its whole link group.
3139 let sel = project.expandLinks(store.selection)
3140 guard !sel.isEmpty else { return }
3141 store.mutate { model in
3142 model.clips.removeAll { sel.contains($0.id) }
3143 model.pruneTrailingEmptyTracks()
3144 }
3145 }
3146
3147 /// ⌥⌫ — ripple delete: remove the selection and close the time gap it
3148 /// occupied, shifting everything later (on ALL tracks, so multicam sync
3149 /// holds) left by the gap.
3150 func rippleDelete() {
3151 let sel = project.expandLinks(store.selection)
3152 guard !sel.isEmpty else { return }
3153 let victims = project.clips.filter { sel.contains($0.id) }
3154 // Storyboard panels are start-only points; only solid clips define
3155 // the range that closes.
3156 let solid = victims.filter { $0.kind != .storyboard }
3157 let start = solid.map(\.start).min()
3158 let end = solid.map(\.end).max()
3159 store.mutate { model in
3160 model.clips.removeAll { sel.contains($0.id) }
3161 if let start, let end, end > start {
3162 let gap = end - start
3163 for i in model.clips.indices where model.clips[i].start >= end - 1e-9 {
3164 model.clips[i].start = max(0, model.clips[i].start - gap)
3165 }
3166 }
3167 model.pruneTrailingEmptyTracks()
3168 }
3169 // Keep the cursor on the same logical content rather than jumping it to
3170 // the cut. If it sat after the removed span, slide it left by the gap so
3171 // the exact frame under it is unchanged — a playing stream keeps rolling
3172 // with no reseek. Inside the span, land on the close point; before it,
3173 // leave it be.
3174 if let start, let end, end > start {
3175 let ph = playback.playhead
3176 if ph >= end - 1e-9 {
3177 playback.shift(by: -(end - start))
3178 } else if ph > start {
3179 playback.seek(to: start)
3180 }
3181 }
3182 }
3183
3184 /// ⌥← / ⌥→ — split the clip(s) under the playhead and ripple-delete the
3185 /// side toward the arrow, closing the gap. With a selection it acts on the
3186 /// selected/linked clips the playhead crosses; with none, on every angle
3187 /// under the playhead (so multicam stays in sync).
3188 func rippleTrimToPlayhead(deleteLeft: Bool) {
3189 let t = quantize(playback.playhead)
3190 let eps = 1e-6
3191 let straddles: (Clip) -> Bool = {
3192 $0.kind != .storyboard && $0.start + eps < t && $0.end - eps > t
3193 }
3194 let sel = project.expandLinks(store.selection)
3195 let hadSelection = !store.selection.isEmpty
3196 // Prefer the selected/linked clips the playhead crosses; fall back to
3197 // every angle under it (so multicam stays in sync).
3198 func pick(_ p: @escaping (Clip) -> Bool) -> [Clip] {
3199 let inSel = project.clips.filter { sel.contains($0.id) && p($0) }
3200 return inSel.isEmpty ? project.clips.filter(p) : inSel
3201 }
3202
3203 let crossing = pick(straddles)
3204 if !crossing.isEmpty {
3205 let gapStart = deleteLeft ? crossing.map(\.start).min()! : t
3206 let gapEnd = deleteLeft ? t : crossing.map(\.end).max()!
3207 let gap = gapEnd - gapStart
3208 guard gap > eps else { return }
3209 let ids = Set(crossing.map(\.id))
3210 store.mutate { model in
3211 for tgt in model.clips where ids.contains(tgt.id) {
3212 guard let i = model.clips.firstIndex(where: { $0.id == tgt.id }) else { continue }
3213 if deleteLeft {
3214 model.clips[i].srcIn = tgt.srcIn + (t - tgt.start) * tgt.speed
3215 model.clips[i].start = t
3216 model.clips[i].duration = tgt.end - t
3217 } else {
3218 model.clips[i].duration = t - tgt.start
3219 }
3220 }
3221 // Close the gap on EVERY track (the kept right pieces start at
3222 // gapEnd, so they ride left with everything else).
3223 for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 {
3224 model.clips[i].start = max(0, model.clips[i].start - gap)
3225 }
3226 model.pruneTrailingEmptyTracks()
3227 }
3228 // The kept piece keeps its id, so a trim that started from a
3229 // selection leaves that resulting clip selected.
3230 if hadSelection { store.selection = ids }
3231 playback.seek(to: gapStart)
3232 return
3233 }
3234
3235 // Nothing straddles the playhead — it's sitting exactly on a cut (e.g.
3236 // right after S). There's nothing to split, so ripple-delete the whole
3237 // clip butting the playhead on the side being trimmed, closing the gap.
3238 let abuts: (Clip) -> Bool = deleteLeft
3239 ? { $0.kind != .storyboard && abs($0.end - t) < eps }
3240 : { $0.kind != .storyboard && abs($0.start - t) < eps }
3241 let adjacent = pick(abuts)
3242 guard !adjacent.isEmpty else { return }
3243 let gapStart = adjacent.map(\.start).min()!
3244 let gapEnd = adjacent.map(\.end).max()!
3245 let gap = gapEnd - gapStart
3246 guard gap > eps else { return }
3247 let ids = Set(adjacent.map(\.id))
3248 store.mutate { model in
3249 model.clips.removeAll { ids.contains($0.id) }
3250 for i in model.clips.indices where model.clips[i].start >= gapEnd - 1e-9 {
3251 model.clips[i].start = max(0, model.clips[i].start - gap)
3252 }
3253 model.pruneTrailingEmptyTracks()
3254 }
3255 playback.seek(to: gapStart)
3256 }
3257
3258 /// The closeable blank column at a moment: nothing under it on any track,
3259 /// bounded by the previous content end and the next content start.
3260 func blankGap(at time: Double) -> (start: Double, end: Double)? {
3261 let solids = project.clips.filter { $0.kind != .storyboard }
3262 guard !solids.contains(where: { $0.start - 1e-6 < time && $0.end - 1e-6 > time })
3263 else { return nil } // something is under the playhead — not blank
3264 let start = solids.filter { $0.end <= time + 1e-6 }.map(\.end).max() ?? 0
3265 guard let end = solids.filter({ $0.start > time - 1e-6 }).map(\.start).min(),
3266 end - start > 1e-6 else { return nil } // trailing/zero blank
3267 return (start, end)
3268 }
3269
3270 func closeBlankSpaceAtPlayhead() {
3271 closeBlankSpace(at: quantize(playback.playhead))
3272 }
3273
3274 /// "Delete the space": ripple the blank column at `time` closed on every
3275 /// track. Bound to ⌫/⌥⌫ with no selection, and the empty-lane menu.
3276 func closeBlankSpace(at time: Double) {
3277 guard let g = blankGap(at: time) else { return }
3278 let gap = g.end - g.start
3279 store.mutate { model in
3280 for i in model.clips.indices where model.clips[i].start >= g.end - 1e-9 {
3281 model.clips[i].start = max(0, model.clips[i].start - gap)
3282 }
3283 model.pruneTrailingEmptyTracks()
3284 }
3285 playback.seek(to: g.start)
3286 }
3287
3288 /// Esc — drop the selection and stop playback (cancels an open drag too).
3289 override func cancelOperation(_ sender: Any?) {
3290 if store.gestureBaseModel != nil { store.cancelGesture() }
3291 store.selection = []
3292 // Escape during playback rewinds to where playback started; otherwise
3293 // it's a plain stop.
3294 if !playback.stopAndRevert() { playback.setRate(0) }
3295 needsDisplay = true
3296 }
3297
3298 // MARK: - Responder-chain edit commands (menu Cut/Copy/Paste/Select All)
3299
3300 override func selectAll(_ sender: Any?) {
3301 store.selection = Set(project.clips.map(\.id))
3302 }
3303
3304 private var selectedStoryboardClip: Clip? {
3305 project.clips.first { store.selection.contains($0.id) && $0.kind == .storyboard }
3306 }
3307
3308 static let clipsPasteboardType = NSPasteboard.PasteboardType("com.sequencer.clips")
3309
3310 private struct ClipsTransfer: Codable {
3311 var fps: Double
3312 var clips: [Clip]
3313 var media: [MediaItem]
3314 var rasters: [UUID: Data] // boardId → raster PNG for storyboard panels
3315 }
3316
3317 /// ⌘C — copying clips IS copying for Fusion: the text on the pasteboard
3318 /// is Loader Lua (paste straight into the Flow view). A full clip payload
3319 /// rides along for ⌘V back into Sequencer, and a lone storyboard panel
3320 /// also puts its flattened PNG up for other apps.
3321 @objc func copy(_ sender: Any?) {
3322 let sel = project.expandLinks(store.selection)
3323 let clips = project.clips.filter { sel.contains($0.id) }
3324 .sorted { $0.start < $1.start }
3325 guard !clips.isEmpty else {
3326 NotificationCenter.default.post(name: .transientStatus, object: nil,
3327 userInfo: ["text": "Nothing selected to copy"])
3328 return
3329 }
3330 let pb = NSPasteboard.general
3331 pb.clearContents()
3332 let loaders = clips.filter { $0.kind == .video && $0.mediaId != nil }
3333 if !loaders.isEmpty {
3334 pb.setString(FusionExport.loaderLua(for: loaders, project: project),
3335 forType: .string)
3336 }
3337 var rasters: [UUID: Data] = [:]
3338 for c in clips where c.kind == .storyboard {
3339 if let b = c.board, let png = boards.rasterPNGData(b.id) {
3340 rasters[b.id] = png
3341 }
3342 }
3343 let mediaIds = Set(clips.compactMap(\.mediaId))
3344 let transfer = ClipsTransfer(fps: project.fps, clips: clips,
3345 media: project.media.filter { mediaIds.contains($0.id) },
3346 rasters: rasters)
3347 if let data = try? JSONEncoder().encode(transfer) {
3348 pb.setData(data, forType: Self.clipsPasteboardType)
3349 }
3350 if clips.count == 1, clips[0].kind == .storyboard, let board = clips[0].board {
3351 let composite = boards.composite(for: board)
3352 if let tiff = composite.tiffRepresentation,
3353 let rep = NSBitmapImageRep(data: tiff),
3354 let png = rep.representation(using: .png, properties: [:]) {
3355 pb.setData(png, forType: .png)
3356 }
3357 }
3358 let what = "Copied \(clips.count) clip\(clips.count == 1 ? "" : "s")"
3359 NotificationCenter.default.post(name: .transientStatus, object: nil,
3360 userInfo: ["text": what])
3361 }
3362
3363 /// ⌘V — clips on the pasteboard land at the playhead (relative offsets
3364 /// kept, original tracks when they still exist). Otherwise an image or
3365 /// panel pastes INTO the selected storyboard panel as before.
3366 @objc func paste(_ sender: Any?) {
3367 let pb = NSPasteboard.general
3368 if let data = pb.data(forType: Self.clipsPasteboardType),
3369 let transfer = try? JSONDecoder().decode(ClipsTransfer.self, from: data) {
3370 pasteClips(transfer)
3371 return
3372 }
3373 guard let clip = selectedStoryboardClip else {
3374 NotificationCenter.default.post(name: .transientStatus, object: nil,
3375 userInfo: ["text": "Clipboard has no clips — select a storyboard panel to paste an image into"])
3376 return
3377 }
3378 let size = clip.board?.size ?? CGSize(width: 1600, height: 900)
3379 guard let board = boards.panelFromPasteboard(size: size) else {
3380 NotificationCenter.default.post(name: .transientStatus, object: nil,
3381 userInfo: ["text": "Clipboard has no panel or image"])
3382 return
3383 }
3384 store.mutate { model in
3385 guard let i = model.clips.firstIndex(where: { $0.id == clip.id }) else { return }
3386 model.clips[i].board = board
3387 }
3388 NotificationCenter.default.post(name: .transientStatus, object: nil,
3389 userInfo: ["text": "Pasted panel"])
3390 }
3391
3392 private func pasteClips(_ t: ClipsTransfer) {
3393 let t0 = t.clips.map(\.start).min() ?? 0
3394 let offset = quantize(playback.playhead - t0)
3395 var newIds: Set<UUID> = []
3396 store.mutate { model in
3397 var mediaMap: [UUID: UUID] = [:]
3398 for m in t.media {
3399 if let existing = model.media.first(where: {
3400 ($0.cacheKey == m.cacheKey && !m.cacheKey.isEmpty) || $0.id == m.id
3401 }) {
3402 mediaMap[m.id] = existing.id
3403 } else {
3404 model.media.append(m)
3405 mediaMap[m.id] = m.id
3406 }
3407 }
3408 var linkMap: [UUID: UUID] = [:]
3409 var trackMap: [Int: Int] = [:] // source video lane → lane in this project
3410 for var c in t.clips {
3411 let oldBoardId = c.board?.id
3412 c.id = UUID()
3413 c.start = max(0, c.start + offset)
3414 if let mid = c.mediaId { c.mediaId = mediaMap[mid] }
3415 if let l = c.linkId {
3416 if linkMap[l] == nil { linkMap[l] = UUID() }
3417 c.linkId = linkMap[l]
3418 }
3419 if c.kind == .storyboard {
3420 var b = c.board ?? model.newBoard()
3421 b.id = UUID()
3422 b.revision = 0
3423 if let old = oldBoardId, let png = t.rasters[old] {
3424 boards.setRaster(fromPNG: png, boardId: b.id)
3425 }
3426 c.board = b
3427 c.track = .storyboard
3428 } else if let vi = c.track.videoIndex, !model.tracks.indices.contains(vi) {
3429 // Source lane this project doesn't have — make one (deduped).
3430 if trackMap[vi] == nil { trackMap[vi] = model.addTrack() }
3431 c.track = .video(trackMap[vi]!)
3432 }
3433 newIds.insert(c.id)
3434 model.clips.append(c)
3435 }
3436 }
3437 store.selection = newIds
3438 NotificationCenter.default.post(name: .transientStatus, object: nil,
3439 userInfo: ["text": "Pasted \(newIds.count) clip\(newIds.count == 1 ? "" : "s") at the playhead"])
3440 }
3441
3442 @objc func cut(_ sender: Any?) {
3443 copy(sender)
3444 deleteSelection()
3445 }
3446
3447 // MARK: - Track actions
3448
3449 func resetTrackVisibility() {
3450 session.hiddenTracks = []
3451 session.focusedTracks = []
3452 session.fusionHidden = false
3453 session.fusionFocus = false
3454 session.priorityPane = nil
3455 NotificationCenter.default.post(name: .transientStatus, object: nil,
3456 userInfo: ["text": "All tracks visible"])
3457 }
3458
3459 func deleteEmptyTracks() {
3460 store.mutate { $0.pruneEmptyTracks() }
3461 }
3462
3463 /// New storyboard panel at the playhead on THE storyboard track
3464 /// (created on demand — panels are isolated to it).
3465 func addStoryboardPanel(at time: Double? = nil) {
3466 var t = quantize(time ?? playback.playhead)
3467 // Don't stack a new panel on top of one already starting here: bump it a
3468 // second into the future, or halfway to the next panel if one sits
3469 // within that second.
3470 do {
3471 let starts = project.clips
3472 .filter { $0.kind == .storyboard }
3473 .map(\.start)
3474 if starts.contains(where: { abs($0 - t) < frameDur / 2 }) {
3475 let next = starts.filter { $0 > t + frameDur / 2 }.min()
3476 // A panel exactly a second out still collides if we land on it,
3477 // so treat "within a second" inclusively and split the gap.
3478 if let next, next < t + 1 + frameDur / 2 {
3479 t = quantize((t + next) / 2)
3480 } else {
3481 t = quantize(t + 1)
3482 }
3483 }
3484 }
3485 var newClipId: UUID?
3486 store.mutate { model in
3487 let clip = Clip(mediaId: nil, track: .storyboard, start: t, srcIn: 0,
3488 duration: 3, kind: .storyboard, board: model.newBoard())
3489 newClipId = clip.id
3490 model.clips.append(clip)
3491 }
3492 if let id = newClipId {
3493 store.selection = [id]
3494 }
3495 // Move the playhead onto the new panel so mashing B keeps stepping
3496 // forward a panel at a time.
3497 playback.seek(to: t)
3498 }
3499
3500 /// B — new empty panel one second after the panel under the playhead (or
3501 /// the playhead itself), and the playhead jumps onto it: mash B to rough
3502 /// out shot timings a second apart before the real timings are known.
3503 func addPanelOneSecondLater() {
3504 let pc = playback
3505 var t = quantize(pc.playhead + 1)
3506 if let panel = project.clipAt(track: .storyboard, time: pc.playhead,
3507 kind: .storyboard) {
3508 t = quantize(panel.start + 1)
3509 }
3510 // addStoryboardPanel resolves any collision and seeks the playhead onto
3511 // the panel it actually created.
3512 addStoryboardPanel(at: t)
3513 }
3514
3515 // MARK: - Markers
3516
3517 /// Drop a marker at the playhead, or remove the one already sitting there
3518 /// (so the same key toggles). One undo step.
3519 func toggleMarkerAtPlayhead() {
3520 let t = quantize(playback.playhead)
3521 store.mutate { model in
3522 let half = 0.5 / max(1, model.fps)
3523 if let idx = model.markers.firstIndex(where: { abs($0.time - t) < half }) {
3524 model.markers.remove(at: idx)
3525 } else {
3526 model.markers.append(Marker(time: t))
3527 }
3528 }
3529 }
3530
3531 /// ⌘] / ⌘[ — jump the playhead to the next/previous storyboard panel start.
3532 func goToNextStoryboardPanel() {
3533 let t = playback.playhead
3534 guard let next = project.clips
3535 .filter({ $0.kind == .storyboard && $0.start > t + frameDur / 2 })
3536 .map(\.start).min()
3537 else { return }
3538 playback.seek(to: next)
3539 }
3540
3541 func goToPrevStoryboardPanel() {
3542 let t = playback.playhead
3543 guard let prev = project.clips
3544 .filter({ $0.kind == .storyboard && $0.start < t - frameDur / 2 })
3545 .map(\.start).max()
3546 else { return }
3547 playback.seek(to: prev)
3548 }
3549
3550 func goToNextMarker() {
3551 guard let m = project.nextMarker(after: playback.playhead) else { return }
3552 playback.seek(to: m.time)
3553 }
3554
3555 func goToPrevMarker() {
3556 guard let m = project.prevMarker(before: playback.playhead) else { return }
3557 playback.seek(to: m.time)
3558 }
3559
3560 func clearAllMarkers() {
3561 guard !project.markers.isEmpty else { return }
3562 store.mutate { $0.markers.removeAll() }
3563 }
3564
3565 private func deleteMarker(_ id: UUID) {
3566 store.mutate { $0.markers.removeAll { $0.id == id } }
3567 }
3568
3569 /// Set (or clear) a marker's label. Driven by the inline name field in the
3570 /// marker's right-click menu.
3571 private func setMarkerLabel(_ id: UUID, _ label: String) {
3572 store.mutate { model in
3573 if let i = model.markers.firstIndex(where: { $0.id == id }) {
3574 model.markers[i].label = label
3575 }
3576 }
3577 }
3578
3579 // MARK: - Context menus (right-click everywhere)
3580
3581 private var ctxTrack: TrackRef?
3582 private var ctxCompPath: String?
3583 private var ctxMarkerId: UUID?
3584 // The inline name field of the open marker menu, committed on menuDidClose.
3585 private weak var markerNameField: MarkerNameMenuField?
3586 private var markerNameOriginal = ""
3587
3588 override func menu(for event: NSEvent) -> NSMenu? {
3589 let p = convert(event.locationInWindow, from: nil)
3590 lastMousePoint = p
3591 let menu = NSMenu()
3592 func add(_ title: String, _ action: Selector, key: String = "",
3593 mods: NSEvent.ModifierFlags = []) {
3594 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
3595 mi.target = self
3596 mi.keyEquivalentModifierMask = mods
3597 menu.addItem(mi)
3598 }
3599
3600 // Ruler: markers (rename/delete an existing flag, or add one here).
3601 if p.y < rulerH {
3602 if let m = markerAt(point: p) {
3603 ctxMarkerId = m.id
3604 // Name lives inline in the menu (like Frame Rate), not a popup.
3605 // Committed on menuDidClose so Return / click-away all persist.
3606 let nameField = MarkerNameMenuField(name: m.label)
3607 nameField.onReturn = { [weak menu] in menu?.cancelTracking() }
3608 markerNameField = nameField
3609 markerNameOriginal = m.label
3610 menu.delegate = self
3611 let nameItem = NSMenuItem()
3612 nameItem.view = nameField
3613 menu.addItem(nameItem)
3614 menu.addItem(.separator())
3615 add("Delete Marker", #selector(ctxDeleteMarker), key: "\u{8}")
3616 } else {
3617 add("Add Marker Here", #selector(ctxAddMarkerHere))
3618 }
3619 if !project.markers.isEmpty {
3620 menu.addItem(.separator())
3621 add("Clear All Markers", #selector(ctxClearMarkers))
3622 }
3623 return menu
3624 }
3625
3626 // Fusion comps
3627 if let comp = compAt(point: p) {
3628 comps.selectedCompPath = comp.path
3629 ctxCompPath = comp.path
3630 needsDisplay = true
3631 let preferred = project.preferredTakes.contains(comp.name)
3632 add(preferred ? "Unmark Preferred Take" : "Set as Preferred Take",
3633 #selector(ctxTogglePreferred), key: "t")
3634 add("Open in Fusion", #selector(ctxOpenComp))
3635 menu.addItem(.separator())
3636 add("Rescan Comps", #selector(ctxRescanComps))
3637 return menu
3638 }
3639 if fusionBandH > 0, p.y > fusionTop, p.y < lanesTop {
3640 add(session.fusionHidden ? "Show Fusion Preview" : "Hide Fusion Preview",
3641 #selector(ctxToggleFusionHidden))
3642 add(session.fusionFocus ? "Unfocus Fusion" : "Focus Fusion",
3643 #selector(ctxToggleFusionFocus))
3644 add("Rescan Comps", #selector(ctxRescanComps))
3645 return menu
3646 }
3647
3648 // Track header
3649 if p.x < headerW, let row = rowAt(y: p.y), let ref = laneRef(row: row) {
3650 ctxTrack = ref
3651 add(session.hiddenTracks.contains(ref) ? "Show Preview" : "Hide Preview",
3652 #selector(ctxToggleHidden))
3653 add(session.focusedTracks.contains(ref) ? "Unfocus" : "Focus",
3654 #selector(ctxToggleFocus))
3655 add("Show All Tracks", #selector(ctxResetVisibility))
3656 menu.addItem(.separator())
3657 // Only real video lanes can be deleted (the storyboard lane clears
3658 // itself when its panels are gone).
3659 if ref.videoIndex != nil { add("Delete Track", #selector(ctxDeleteTrack)) }
3660 add("Delete Empty Tracks", #selector(ctxDeleteEmptyTracks))
3661 return menu
3662 }
3663
3664 // Clips
3665 if let (clip, _) = clipAt(point: p) {
3666 if !store.selection.contains(clip.id) { store.selection = [clip.id] }
3667 add("Split at Playhead", #selector(ctxSplit), key: "s")
3668 if clip.kind == .storyboard {
3669 add("Split Storyboard at Playhead", #selector(ctxSplitStoryboard), key: "b")
3670 add("Split Storyboard, New Shot", #selector(ctxSplitStoryboardNewShot),
3671 key: "B", mods: .shift)
3672 add("Is New Shot", #selector(ctxToggleNewShot), key: "n")
3673 menu.items.last?.state = clip.newShot ? .on : .off
3674 add("New Panel 1 s Later", #selector(ctxPanelLater))
3675 add("Open in Storyboard Window", #selector(ctxOpenBoard))
3676 }
3677 add(clip.muted ? "Unmute" : "Mute", #selector(ctxMute), key: "m")
3678 if clip.speed != 1 { add("Reset Speed (×1)", #selector(ctxResetSpeed)) }
3679 menu.addItem(.separator())
3680 if store.selection.count > 1 { add("Link Clips", #selector(ctxLink), key: "g") }
3681 if clip.linkId != nil { add("Unlink Clips", #selector(ctxUnlink)) }
3682 add("Copy", #selector(ctxCopy))
3683 menu.addItem(.separator())
3684 add("Delete", #selector(ctxDelete), key: "\u{8}")
3685 add("Ripple Delete", #selector(ctxRippleDelete), key: "\\")
3686 return menu
3687 }
3688
3689 // Empty lane space
3690 if rowAt(y: p.y) != nil {
3691 if blankGap(at: quantize(secondsFor(p.x))) != nil {
3692 add("Delete the Space (Close Gap)", #selector(ctxCloseGap),
3693 key: "\u{8}")
3694 menu.addItem(.separator())
3695 }
3696 add("Add Storyboard Panel Here", #selector(ctxAddPanelHere))
3697 if !project.overlaps().isEmpty {
3698 add("Move Overlaps to Separate Tracks", #selector(ctxMoveOverlaps),
3699 key: "o", mods: .option)
3700 }
3701 add("Paste Panel", #selector(ctxPaste))
3702 return menu
3703 }
3704 return nil
3705 }
3706
3707 @objc private func ctxTogglePreferred() { comps.togglePreferredTake() }
3708 @objc private func ctxOpenComp() {
3709 if let comp = comps.comp(at: ctxCompPath) {
3710 comps.openInFusion(comp)
3711 }
3712 }
3713 @objc private func ctxRescanComps() { comps.rescan() }
3714 @objc private func ctxDeleteMarker() { if let id = ctxMarkerId { deleteMarker(id) } }
3715 @objc private func ctxClearMarkers() { clearAllMarkers() }
3716 @objc private func ctxAddMarkerHere() {
3717 let t = max(0, quantize(secondsFor(lastMousePoint.x)))
3718 store.mutate { model in
3719 let half = 0.5 / max(1, model.fps)
3720 if !model.markers.contains(where: { abs($0.time - t) < half }) {
3721 model.markers.append(Marker(time: t))
3722 }
3723 }
3724 }
3725 @objc private func ctxToggleFusionHidden() { session.fusionHidden.toggle(); needsDisplay = true }
3726 @objc private func ctxToggleFusionFocus() { session.fusionFocus.toggle(); needsDisplay = true }
3727 @objc private func ctxToggleHidden() {
3728 if let ref = ctxTrack { session.toggleHidden(ref); needsDisplay = true }
3729 }
3730 @objc private func ctxToggleFocus() {
3731 if let ref = ctxTrack { session.toggleFocus(ref); needsDisplay = true }
3732 }
3733 @objc private func ctxResetVisibility() { resetTrackVisibility() }
3734 @objc private func ctxDeleteTrack() {
3735 guard let vi = ctxTrack?.videoIndex else { return }
3736 store.mutate { model in
3737 guard model.tracks.count > 1 else { return }
3738 model.clips.removeAll { $0.track == .video(vi) }
3739 model.removeTrack(at: vi) // renumbers the lanes above it
3740 }
3741 }
3742 @objc private func ctxDeleteEmptyTracks() { deleteEmptyTracks() }
3743 @objc private func ctxSplit() { split() }
3744 @objc private func ctxSplitStoryboard() { splitStoryboardAtPlayhead() }
3745 @objc private func ctxSplitStoryboardNewShot() { splitStoryboardAtPlayhead(newShot: true) }
3746 @objc private func ctxOpenBoard() {
3747 if let id = store.selection.first,
3748 project.clip(id)?.kind == .storyboard {
3749 StoryboardEditor.shared.open(clipId: id, ctx: ctx)
3750 }
3751 }
3752 @objc private func ctxMute() { toggleMute() }
3753 @objc private func ctxResetSpeed() {
3754 let sel = store.selection
3755 store.mutate { model in
3756 for i in model.clips.indices where sel.contains(model.clips[i].id) {
3757 model.clips[i].speed = 1
3758 }
3759 }
3760 }
3761 @objc private func ctxLink() { linkSelection() }
3762 @objc private func ctxUnlink() { unlinkSelection() }
3763 @objc private func ctxCopy() { copy(nil) }
3764 @objc private func ctxToggleNewShot() { toggleNewShot() }
3765 @objc private func ctxPanelLater() { addPanelOneSecondLater() }
3766 @objc private func ctxDelete() { deleteSelection() }
3767 @objc private func ctxRippleDelete() { rippleDelete() }
3768 @objc private func ctxMoveOverlaps() { moveOverlapsToSeparateTracks() }
3769 @objc private func ctxPaste() { paste(nil) }
3770 @objc private func ctxCloseGap() { closeBlankSpace(at: quantize(secondsFor(lastMousePoint.x))) }
3771 @objc private func ctxAddPanelHere() {
3772 playback.seek(to: max(0, quantize(secondsFor(lastMousePoint.x))))
3773 addStoryboardPanel()
3774 }
3775
3776 // MARK: - Zoom & pan
3777
3778 /// Overscroll: ~600 px of empty room before 0 and a couple of minutes
3779 /// past the last clip (the playhead may live out there), but bounded.
3780 private func clampOrigin(_ o: Double) -> Double {
3781 let overscroll = 600.0 / pxPerSecond
3782 rebuildSceneIfNeeded()
3783 return min(max(o, -overscroll), cachedTimelineDuration + 120)
3784 }
3785
3786 override func scrollWheel(with event: NSEvent) {
3787 if event.modifierFlags.contains(.command) {
3788 zoom(by: 1 + event.scrollingDeltaY * 0.01, anchorX: convert(event.locationInWindow, from: nil).x)
3789 } else {
3790 let dy = event.scrollingDeltaY
3791 // Vertical scrolling moves through the tracks when they overflow;
3792 // otherwise (and for the horizontal axis) it pans time.
3793 if maxScrollY > 0, abs(dy) > abs(event.scrollingDeltaX) {
3794 scrollY = min(max(0, scrollY - dy), maxScrollY)
3795 } else {
3796 let dx = event.scrollingDeltaX != 0 ? event.scrollingDeltaX : dy
3797 originSecond = clampOrigin(originSecond - Double(dx) / pxPerSecond)
3798 }
3799 }
3800 needsDisplay = true
3801 }
3802
3803 override func magnify(with event: NSEvent) {
3804 zoom(by: 1 + event.magnification, anchorX: convert(event.locationInWindow, from: nil).x)
3805 }
3806
3807 private func zoom(by factor: CGFloat, anchorX: CGFloat) {
3808 let anchorSec = secondsFor(anchorX)
3809 pxPerSecond = min(max(pxPerSecond * Double(factor), 0.05), 4000)
3810 originSecond = anchorSec - Double(anchorX - headerW) / pxPerSecond
3811 needsDisplay = true
3812 }
3813
3814 func zoomToFit() {
3815 let dur = max(10, project.timelineDuration)
3816 pxPerSecond = Double(bounds.width - headerW) * 0.92 / dur
3817 originSecond = -0.04 * dur
3818 needsDisplay = true
3819 }
3820
3821 // MARK: - File drop import
3822
3823 /// Live preview of where a hovering file drop will land: the resolved
3824 /// streams, their shared anchor, the drop time, and the first row.
3825 private struct FileDropPreview {
3826 var streams: [DropStream]
3827 var minOffset: Double
3828 var dropSec: Double
3829 var baseRow: Int
3830 }
3831 private var fileDropPreview: FileDropPreview?
3832 private var dropDurations: [String: Double] = [:] // path → probed seconds
3833 private var dropProbing: Set<String> = []
3834
3835 private func droppableFiles(from sender: NSDraggingInfo) -> [URL] {
3836 guard let urls = sender.draggingPasteboard
3837 .readObjects(forClasses: [NSURL.self]) as? [URL] else { return [] }
3838 return urls.filter {
3839 UI.importableExtensions.contains($0.pathExtension.lowercased())
3840 || $0.lastPathComponent == "sync.json"
3841 || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
3842 }
3843 }
3844
3845 /// Probe (once) each stream's duration so the preview rect has real width.
3846 private func ensureDropDurations(_ streams: [DropStream]) {
3847 for s in streams where dropDurations[s.url.path] == nil
3848 && !dropProbing.contains(s.url.path) {
3849 dropProbing.insert(s.url.path)
3850 MediaPipeline.shared.probeDuration(s.url) { [weak self] d in
3851 guard let self else { return }
3852 self.dropProbing.remove(s.url.path)
3853 if let d, d > 0 { self.dropDurations[s.url.path] = d; self.needsDisplay = true }
3854 }
3855 }
3856 }
3857
3858 private func updateFileDropPreview(_ sender: NSDraggingInfo) {
3859 let streams = expandDropStreams(droppableFiles(from: sender))
3860 guard !streams.isEmpty else { fileDropPreview = nil; needsDisplay = true; return }
3861 let p = convert(sender.draggingLocation, from: nil)
3862 let count = laneRows.count
3863 fileDropPreview = FileDropPreview(
3864 streams: streams,
3865 minOffset: streams.map(\.offset).min() ?? 0,
3866 dropSec: max(0, quantize(secondsFor(p.x))),
3867 baseRow: rowAt(y: p.y).map { min($0, count) } ?? count)
3868 ensureDropDurations(streams)
3869 needsDisplay = true
3870 }
3871
3872 override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
3873 updateFileDropPreview(sender)
3874 return .copy
3875 }
3876
3877 override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
3878 updateFileDropPreview(sender)
3879 return .copy
3880 }
3881
3882 override func draggingExited(_ sender: NSDraggingInfo?) {
3883 fileDropPreview = nil
3884 needsDisplay = true
3885 }
3886
3887 override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
3888 fileDropPreview = nil
3889 needsDisplay = true
3890 let p = convert(sender.draggingLocation, from: nil)
3891 let files = droppableFiles(from: sender)
3892 guard !files.isEmpty else { return false }
3893 let dropSec = max(0, quantize(secondsFor(p.x)))
3894 let row = rowAt(y: p.y).flatMap { $0 < laneRows.count ? $0 : nil }
3895 importFiles(files, atSecond: dropSec, targetRow: row)
3896 return true
3897 }
3898
3899 /// Draw each hovering stream where it will actually land — real start time,
3900 /// probed width, one row per stream — creating ghost lanes below the last
3901 /// track exactly the way dragging an existing clip down does.
3902 private func drawFileDropPreview() {
3903 guard let dp = fileDropPreview else { return }
3904 let count = laneRows.count
3905 for (i, s) in dp.streams.enumerated() {
3906 let row = dp.baseRow + i
3907 let lane = laneRect(row: row)
3908 guard lane.minY < bounds.maxY, lane.maxY > lanesTop else { continue }
3909 // Ghost lane outline for rows that don't exist yet.
3910 if row >= count {
3911 let outline = NSBezierPath(roundedRect: lane.insetBy(dx: 2, dy: 2),
3912 xRadius: 4, yRadius: 4)
3913 outline.setLineDash([4, 4], count: 2, phase: 0)
3914 Theme.dragHint.withAlphaComponent(0.5).setStroke()
3915 outline.stroke()
3916 }
3917 let start = max(0, dp.dropSec + (s.offset - dp.minOffset))
3918 let known = dropDurations[s.url.path]
3919 let x0 = xFor(start)
3920 let w = max(3, CGFloat(known ?? 2) * CGFloat(pxPerSecond))
3921 let rect = NSRect(x: x0, y: lane.minY + 1, width: w, height: lane.height - 2)
3922 guard rect.maxX > headerW, rect.minX < bounds.width else { continue }
3923 let body = NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3)
3924 Theme.dragHint.withAlphaComponent(known != nil ? 0.35 : 0.18).setFill()
3925 body.fill()
3926 body.lineWidth = 1.5
3927 if known == nil { body.setLineDash([3, 3], count: 2, phase: 0) } // still probing
3928 Theme.dragHint.withAlphaComponent(0.9).setStroke()
3929 body.stroke()
3930 }
3931 }
3932
3933 /// One stream that a drop resolves to: a media file and its relative
3934 /// timeline offset (from a sync.json manifest, else 0).
3935 struct DropStream { let url: URL; let offset: Double }
3936
3937 /// Expand a raw drop (files, folders, sync.json manifests) into the ordered,
3938 /// de-duplicated list of media streams it represents — the single source of
3939 /// truth shared by the landing preview and the actual import.
3940 func expandDropStreams(_ rawURLs: [URL]) -> [DropStream] {
3941 var out: [DropStream] = []
3942 var manifestByDir: [String: SyncManifest?] = [:]
3943 func manifest(inDir dir: URL) -> SyncManifest? {
3944 if let cached = manifestByDir[dir.path] { return cached }
3945 let m = SyncManifest.load(dir.appendingPathComponent("sync.json"))
3946 manifestByDir[dir.path] = m
3947 return m
3948 }
3949 func addStreams(_ m: SyncManifest, dir: URL) {
3950 for (file, off) in m.offsetsByFile {
3951 let f = dir.appendingPathComponent(file)
3952 guard FileManager.default.fileExists(atPath: f.path) else { continue }
3953 out.append(DropStream(url: f, offset: off))
3954 }
3955 }
3956 for u in rawURLs {
3957 let isDir = (try? u.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
3958 if u.lastPathComponent == "sync.json", let m = SyncManifest.load(u) {
3959 addStreams(m, dir: u.deletingLastPathComponent())
3960 } else if isDir, let m = SyncManifest.load(u.appendingPathComponent("sync.json")) {
3961 addStreams(m, dir: u)
3962 } else if isDir {
3963 continue // a plain folder with no manifest — don't dump its contents
3964 } else {
3965 // A media file dropped alongside a sync.json inherits its offset.
3966 let off = manifest(inDir: u.deletingLastPathComponent())?
3967 .offsetsByFile[u.lastPathComponent] ?? 0
3968 out.append(DropStream(url: u, offset: off))
3969 }
3970 }
3971 var seen = Set<String>()
3972 return out.filter { seen.insert($0.url.path).inserted }
3973 }
3974
3975 /// Probes files off-main, then lands them in one undoable mutation.
3976 /// Files fill lanes downward from `targetRow`, reusing existing tracks
3977 /// before adding new ones (a dropped multicam set fills the lanes below).
3978 /// A recorder `sync.json` (dropped directly, as a folder, or sitting next
3979 /// to the media) offsets each stream so the session lands in sync.
3980 func importFiles(_ rawURLs: [URL], atSecond: Double, targetRow: Int?) {
3981 let streams = expandDropStreams(rawURLs)
3982 guard !streams.isEmpty else { return }
3983 let urls = streams.map(\.url)
3984 let offsetByPath = Dictionary(streams.map { ($0.url.path, $0.offset) },
3985 uniquingKeysWith: { a, _ in a })
3986 // Anchor the earliest stream at the drop point; the rest keep their
3987 // relative spacing.
3988 let minOffset = streams.map(\.offset).min() ?? 0
3989
3990 NotificationCenter.default.post(name: .transientStatus, object: nil,
3991 userInfo: ["text": "Importing \(urls.count) file\(urls.count == 1 ? "" : "s")…"])
3992 let group = DispatchGroup()
3993 var items: [MediaItem?] = Array(repeating: nil, count: urls.count)
3994 for (i, url) in urls.enumerated() {
3995 group.enter()
3996 MediaPipeline.shared.importFile(url) { item in
3997 items[i] = item
3998 group.leave()
3999 }
4000 }
4001 group.notify(queue: .main) { [weak self] in
4002 guard let self else { return }
4003 let ok = items.compactMap { $0 }
4004 guard !ok.isEmpty else {
4005 NotificationCenter.default.post(name: .transientStatus, object: nil,
4006 userInfo: ["text": "Import failed: Could not probe media"])
4007 return
4008 }
4009 self.store.mutate { model in
4010 if model.clips.isEmpty, let first = ok.first(where: { !$0.isAudio }),
4011 first.fps > 0 {
4012 model.fps = first.fps
4013 }
4014 // Files imported together are a multicam session: link them.
4015 let sessionLink: UUID? = ok.count > 1 ? UUID() : nil
4016 for (i, var item) in ok.enumerated() {
4017 // Re-import of an identical file reuses the existing entry.
4018 if let existing = model.media.first(where: { $0.cacheKey == item.cacheKey }) {
4019 item = existing
4020 } else {
4021 model.media.append(item)
4022 }
4023 // Files land on consecutive rows starting at the drop:
4024 // reuse the existing lanes below the target before making
4025 // new ones (new tracks append at the bottom, so the next
4026 // wanted row keeps matching as we go).
4027 let trackIndex: Int
4028 let rows = model.laneRefs
4029 let wantRow = targetRow.map { $0 + i }
4030 if let row = wantRow, rows.indices.contains(row),
4031 let vi = rows[row].videoIndex {
4032 trackIndex = vi
4033 } else {
4034 trackIndex = model.addTrack()
4035 }
4036 let off = (offsetByPath[item.path] ?? minOffset) - minOffset
4037 model.clips.append(Clip(
4038 mediaId: item.id, track: .video(trackIndex),
4039 start: atSecond + off, srcIn: 0, duration: item.duration,
4040 kind: item.isAudio ? .audio : .video,
4041 linkId: sessionLink))
4042 }
4043 }
4044 NotificationCenter.default.post(name: .transientStatus, object: nil,
4045 userInfo: ["text": "Imported \(ok.count) file\(ok.count == 1 ? "" : "s")"])
4046 }
4047 }
4048
4049 func zoomIn() { zoom(by: 1.4, anchorX: bounds.midX) }
4050 func zoomOut() { zoom(by: 1 / 1.4, anchorX: bounds.midX) }
4051
4052 // MARK: - Test hooks
4053
4054 func testXFor(_ seconds: Double) -> CGFloat { xFor(seconds) }
4055 var testPxPerSecond: Double { pxPerSecond }
4056 func testHThumb() -> NSRect { hThumbRect() }
4057 func testVThumb() -> NSRect { vThumbRect() }
4058 func testSetOrigin(_ sec: Double) { originSecond = sec }
4059 func testSetPxPerSecond(_ p: Double) { pxPerSecond = p }
4060 /// Draw straight into the current graphics context (set by the harness),
4061 /// bypassing `cacheDisplay`'s per-call bitmap allocation so we time `draw`.
4062 func testRedraw() { draw(bounds) }
4063 var testTimelineDuration: Double { project.timelineDuration }
4064 var testVisibleClipCount: Int { rebuildSceneIfNeeded(); return visibleClipCount() }
4065
4066 // MARK: - Colors
4067
4068 private func trackColor(_ ref: TrackRef) -> NSColor {
4069 NSColor(calibratedHue: project.hue(for: ref), saturation: 0.55, brightness: 0.85, alpha: 1)
4070 }
4071 private func trackColorForClip(_ clip: Clip) -> NSColor {
4072 trackColor(clip.track)
4073 }
4074}
4075
4076extension TimelineView: NSMenuDelegate {
4077 /// Commit the marker's inline name when its right-click menu closes — by
4078 /// Return, click-away, or picking another item. One undo step, and none at
4079 /// all if the name is unchanged.
4080 func menuDidClose(_ menu: NSMenu) {
4081 guard let field = markerNameField, let id = ctxMarkerId else { return }
4082 markerNameField = nil
4083 let value = field.text.trimmingCharacters(in: .whitespacesAndNewlines)
4084 if value != markerNameOriginal { setMarkerLabel(id, value) }
4085 }
4086}
4087
4088extension NSImage {
4089 func tinted(_ color: NSColor) -> NSImage {
4090 let img = NSImage(size: size, flipped: false) { rect in
4091 color.set()
4092 rect.fill()
4093 self.draw(in: rect, from: .zero, operation: .destinationIn, fraction: 1)
4094 return true
4095 }
4096 return img
4097 }
4098}
4099
4100/// An inline, editable marker-name row hosted inside the marker's right-click
4101/// menu — type a name and press Return to commit, the way Frame Rate lives in
4102/// the menu instead of a separate dialog. Replaces the old rename popup.
4103/// The menu swallows Return before the field's action fires, so we mirror
4104/// every keystroke into `text` and let `TimelineView.menuDidClose` commit it.
4105final class MarkerNameMenuField: NSView, NSTextFieldDelegate {
4106 private let field = NSTextField()
4107 /// Live copy of what's typed, kept current so the commit doesn't depend on
4108 /// the field editor still being attached as the menu tears down.
4109 private(set) var text: String
4110 /// Set by the menu builder so Return dismisses the menu (which commits).
4111 var onReturn: (() -> Void)?
4112
4113 init(name: String) {
4114 self.text = name
4115 super.init(frame: NSRect(x: 0, y: 0, width: 208, height: 26))
4116 let caption = NSTextField(labelWithString: "Name")
4117 caption.font = .menuFont(ofSize: 0)
4118 caption.textColor = .secondaryLabelColor
4119 caption.frame = NSRect(x: 14, y: 5, width: 38, height: 16)
4120 addSubview(caption)
4121 field.frame = NSRect(x: 52, y: 3, width: 142, height: 20)
4122 field.stringValue = name
4123 field.placeholderString = "Marker name"
4124 field.font = .menuFont(ofSize: 0)
4125 field.focusRingType = .none
4126 field.delegate = self
4127 field.target = self
4128 field.action = #selector(returnPressed)
4129 addSubview(field)
4130 }
4131 required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
4132
4133 override func viewDidMoveToWindow() {
4134 super.viewDidMoveToWindow()
4135 guard window != nil else { return }
4136 // The menu hosts us in its own window; grab focus so keystrokes land in
4137 // the field rather than the menu's key-equivalent matcher.
4138 DispatchQueue.main.async { [weak self] in
4139 guard let self else { return }
4140 self.window?.makeFirstResponder(self.field)
4141 }
4142 }
4143
4144 func controlTextDidChange(_ obj: Notification) { text = field.stringValue }
4145 @objc private func returnPressed() { onReturn?() }
4146}