| 1 | import AppKit |
| 2 | |
| 3 | extension Notification.Name { |
| 4 | static let projectChanged = Notification.Name("projectChanged") |
| 5 | static let selectionChanged = Notification.Name("selectionChanged") |
| 6 | static let playheadChanged = Notification.Name("playheadChanged") |
| 7 | static let mediaStatusChanged = Notification.Name("mediaStatusChanged") |
| 8 | static let transientStatus = Notification.Name("transientStatus") // userInfo["text"] |
| 9 | /// The document's file location or saved/dirty state changed — the window |
| 10 | /// titlebar (proxy icon + edited dot) refreshes off this. |
| 11 | static let documentStateChanged = Notification.Name("documentStateChanged") |
| 12 | /// A track player finished seeking while paused — the frame it's showing |
| 13 | /// changed, so the viewer should re-evaluate what to display. Does NOT |
| 14 | /// re-run playback sync (avoids a seek feedback loop). |
| 15 | static let viewerNeedsRefresh = Notification.Name("viewerNeedsRefresh") |
| 16 | } |
| 17 | |
| 18 | /// Owns the project model, selection, undo, and persistence. |
| 19 | /// Perfect undo = snapshot stack of the (small, value-type) model. |
| 20 | final class Store { |
| 21 | /// The document context that owns this store. Set at construction. |
| 22 | unowned var ctx: DocumentContext! |
| 23 | |
| 24 | private(set) var project = ProjectModel() |
| 25 | var selection: Set<UUID> = [] { |
| 26 | didSet { if selection != oldValue { post(.selectionChanged) } } |
| 27 | } |
| 28 | |
| 29 | /// An undo entry is the model plus the ephemeral cursor state (selection |
| 30 | /// and playhead) as it stood before the edit. Restoring one puts the |
| 31 | /// selection and playhead back too, so undo steps backward in time rather |
| 32 | /// than just reverting clip geometry. |
| 33 | private struct Snapshot { |
| 34 | var model: ProjectModel |
| 35 | var selection: Set<UUID> |
| 36 | var playhead: Double |
| 37 | } |
| 38 | |
| 39 | /// One step on the undo timeline. Model edits and storyboard DRAWING edits |
| 40 | /// share this single stack so ⌘Z / ⌘⇧Z step through them in the exact order |
| 41 | /// they happened — regardless of which window is focused — and drawing is |
| 42 | /// redoable like everything else. (Raster pixels live in `BoardStore`, out of |
| 43 | /// the value-type model; the entry just carries the image to restore.) |
| 44 | private enum UndoEntry { |
| 45 | case model(Snapshot) |
| 46 | case raster(boardId: UUID, image: NSImage?) // nil image = board was blank |
| 47 | } |
| 48 | |
| 49 | private var undoStack: [UndoEntry] = [] |
| 50 | private var redoStack: [UndoEntry] = [] |
| 51 | private var gestureBase: Snapshot? |
| 52 | |
| 53 | var canUndo: Bool { !undoStack.isEmpty || gestureBase != nil } |
| 54 | var canRedo: Bool { !redoStack.isEmpty } |
| 55 | |
| 56 | private func snapshot() -> Snapshot { |
| 57 | Snapshot(model: project, selection: selection, playhead: ctx.playback.playhead) |
| 58 | } |
| 59 | |
| 60 | /// Restore a snapshot wholesale: model, selection (dropping any clips that |
| 61 | /// no longer exist), and playhead. |
| 62 | private func restore(_ s: Snapshot) { |
| 63 | project = s.model |
| 64 | selection = s.selection.intersection(Set(project.clips.map(\.id))) |
| 65 | ctx.playback.seek(to: s.playhead) |
| 66 | changed() |
| 67 | } |
| 68 | |
| 69 | // MARK: - Mutation |
| 70 | |
| 71 | /// One-shot undoable mutation. |
| 72 | func mutate(_ body: (inout ProjectModel) -> Void) { |
| 73 | precondition(gestureBase == nil, "mutate() during an open gesture") |
| 74 | let before = snapshot() |
| 75 | var copy = project |
| 76 | body(&copy) |
| 77 | copy.normalizeStoryboards() |
| 78 | guard copy != project else { return } |
| 79 | pushUndo(before) |
| 80 | project = copy |
| 81 | pruneSelection() |
| 82 | changed() |
| 83 | noteEditLocus(before: before.model, after: copy) |
| 84 | } |
| 85 | |
| 86 | /// Continuous-gesture mutations (drags): one undo entry for the whole |
| 87 | /// gesture, and each update recomputes from the gesture-start snapshot |
| 88 | /// so there is no accumulation error. |
| 89 | func beginGesture() { |
| 90 | precondition(gestureBase == nil) |
| 91 | gestureBase = snapshot() |
| 92 | } |
| 93 | |
| 94 | var gestureBaseModel: ProjectModel? { gestureBase?.model } |
| 95 | |
| 96 | func updateGesture(_ body: (inout ProjectModel) -> Void) { |
| 97 | guard let base = gestureBase?.model else { return } |
| 98 | var copy = base |
| 99 | body(&copy) |
| 100 | copy.normalizeStoryboards() |
| 101 | guard copy != project else { return } |
| 102 | project = copy |
| 103 | post(.projectChanged) |
| 104 | } |
| 105 | |
| 106 | /// `finalize` (e.g. pruning emptied tracks) applies ON TOP of the current |
| 107 | /// mid-gesture state — unlike updateGesture, which recomputes from the |
| 108 | /// base snapshot and would discard the gesture's changes. |
| 109 | func endGesture(finalize: ((inout ProjectModel) -> Void)? = nil) { |
| 110 | guard let base = gestureBase else { return } |
| 111 | // Did the drag move anything on screen (mid-gesture posts drew it)? |
| 112 | let diverged = project != base.model |
| 113 | var copy = project |
| 114 | finalize?(&copy) |
| 115 | copy.normalizeStoryboards() |
| 116 | // A drag that vacated a bottom lane leaves it as an ephemeral drop |
| 117 | // target — collapse it (interior lanes and the clip's own lane stay). |
| 118 | copy.pruneTrailingEmptyTracks() |
| 119 | project = copy |
| 120 | gestureBase = nil |
| 121 | // Post `.projectChanged` exactly once. A net change goes through |
| 122 | // `changed()` (undo + dirty + the post). A net-ZERO drag that still moved |
| 123 | // things mid-gesture (dragged out and back, ephemeral lane pruned) has no |
| 124 | // undo entry, but the view is drawing the mid-gesture state — refresh it. |
| 125 | // A no-op gesture (a click that never dragged) posts nothing. |
| 126 | if project != base.model { |
| 127 | pushUndo(base) |
| 128 | noteEditLocus(before: base.model, after: project) |
| 129 | pruneSelection() |
| 130 | changed() |
| 131 | } else if diverged { |
| 132 | post(.projectChanged) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /// Report where an edit landed on the timeline — the proxy builder keeps |
| 137 | /// chunks around recent edit sites warm (built early, evicted late), since |
| 138 | /// editors scrub and re-play around where they're cutting. |
| 139 | private func noteEditLocus(before: ProjectModel, after: ProjectModel) { |
| 140 | var old: [UUID: Clip] = [:] |
| 141 | for c in before.clips { old[c.id] = c } |
| 142 | var times: [Double] = [] |
| 143 | func add(_ t: Double) { |
| 144 | guard times.count < 4, |
| 145 | !times.contains(where: { abs($0 - t) < 30 }) else { return } |
| 146 | times.append(t) |
| 147 | } |
| 148 | for c in after.clips where c.kind == .video && old[c.id] != c { |
| 149 | if times.count >= 4 { break } |
| 150 | guard let o = old[c.id] else { add(c.start); continue } // new clip |
| 151 | // Warm the EDGE that moved: a right-trim on a long clip should |
| 152 | // anchor at its out point, not the (possibly minutes-away) head. |
| 153 | let durChanged = abs(o.duration - c.duration) > 1e-9 |
| 154 | if durChanged, abs(o.end - c.end) > 1e-9 { add(c.end) } // tail edit |
| 155 | if abs(o.srcIn - c.srcIn) > 1e-9 || abs(o.start - c.start) > 1e-9 |
| 156 | || !durChanged { |
| 157 | add(c.start) // head edit, slip, move, or non-geometry change |
| 158 | } |
| 159 | } |
| 160 | ctx.chunks.noteEdits(times: times) |
| 161 | } |
| 162 | |
| 163 | /// Live, non-undoable edit for a floating control that has no discrete |
| 164 | /// start/end (the colour picker). Unlike a gesture it holds no open state, |
| 165 | /// so timeline edits mid-preview can't trip the gesture precondition. |
| 166 | func preview(_ body: (inout ProjectModel) -> Void) { |
| 167 | body(&project) |
| 168 | post(.projectChanged) |
| 169 | } |
| 170 | |
| 171 | /// Commit a finished preview as ONE undo step, given the pre-preview model. |
| 172 | /// A preview (e.g. the colour picker) doesn't touch selection or playhead, |
| 173 | /// so the current cursor state is also the pre-preview cursor state. |
| 174 | func commitPreview(from model: ProjectModel) { |
| 175 | guard project != model else { return } |
| 176 | pushUndo(Snapshot(model: model, selection: selection, |
| 177 | playhead: ctx.playback.playhead)) |
| 178 | pruneSelection() |
| 179 | changed() |
| 180 | } |
| 181 | |
| 182 | func cancelGesture() { |
| 183 | guard let base = gestureBase else { return } |
| 184 | gestureBase = nil |
| 185 | project = base.model |
| 186 | post(.projectChanged) |
| 187 | } |
| 188 | |
| 189 | func undo() { |
| 190 | if gestureBase != nil { cancelGesture(); return } |
| 191 | guard let entry = undoStack.popLast() else { return } |
| 192 | switch entry { |
| 193 | case .model(let snap): |
| 194 | redoStack.append(.model(snapshot())) |
| 195 | restore(snap) |
| 196 | case .raster(let boardId, let image): |
| 197 | // Swap: what's on screen now becomes the redo target; restore the |
| 198 | // stored (pre-edit) drawing. |
| 199 | redoStack.append(.raster(boardId: boardId, image: ctx.boards.rasterSnapshot(boardId))) |
| 200 | ctx.boards.applyRaster(image, boardId: boardId) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func redo() { |
| 205 | guard let entry = redoStack.popLast() else { return } |
| 206 | switch entry { |
| 207 | case .model(let snap): |
| 208 | undoStack.append(.model(snapshot())) |
| 209 | restore(snap) |
| 210 | case .raster(let boardId, let image): |
| 211 | undoStack.append(.raster(boardId: boardId, image: ctx.boards.rasterSnapshot(boardId))) |
| 212 | ctx.boards.applyRaster(image, boardId: boardId) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | /// Register a storyboard drawing edit on the shared undo timeline — called by |
| 217 | /// `BoardStore.endStroke` after it commits a stroke. `before` is the drawing |
| 218 | /// as it stood BEFORE the stroke, so undo restores it. |
| 219 | func recordRasterEdit(boardId: UUID, before: NSImage?) { |
| 220 | undoStack.append(.raster(boardId: boardId, image: before)) |
| 221 | if undoStack.count > 500 { undoStack.removeFirst() } |
| 222 | redoStack.removeAll() |
| 223 | // The stroke already marked the document dirty (via noteRasterChanged); |
| 224 | // this refreshes the Undo menu's enabled state. |
| 225 | post(.documentStateChanged) |
| 226 | } |
| 227 | |
| 228 | private func pushUndo(_ snapshot: Snapshot) { |
| 229 | undoStack.append(.model(snapshot)) |
| 230 | if undoStack.count > 500 { undoStack.removeFirst() } |
| 231 | redoStack.removeAll() |
| 232 | } |
| 233 | |
| 234 | private func pruneSelection() { |
| 235 | let ids = Set(project.clips.map(\.id)) |
| 236 | selection = selection.filter { ids.contains($0) } |
| 237 | } |
| 238 | |
| 239 | private func changed() { |
| 240 | post(.projectChanged) |
| 241 | post(.documentStateChanged) |
| 242 | // Mark the owning NSDocument dirty; it autosaves in place and drives the |
| 243 | // titlebar edited-dot. No-op for the headless (document-less) context. |
| 244 | ctx.document?.updateChangeCount(.changeDone) |
| 245 | } |
| 246 | |
| 247 | private func post(_ name: Notification.Name) { |
| 248 | NotificationCenter.default.post(name: name, object: nil) |
| 249 | } |
| 250 | |
| 251 | /// Test-only: swap in a model without touching disk or undo history. |
| 252 | func replaceForTest(_ model: ProjectModel) { |
| 253 | var model = model |
| 254 | model.normalizeStoryboards() |
| 255 | project = model |
| 256 | undoStack.removeAll(); redoStack.removeAll(); selection.removeAll() |
| 257 | post(.projectChanged) |
| 258 | } |
| 259 | |
| 260 | // MARK: - Persistence |
| 261 | |
| 262 | static var defaultProjectDir: URL { |
| 263 | FileManager.default.homeDirectoryForCurrentUser |
| 264 | .appendingPathComponent("Documents/Sequencer Projects", isDirectory: true) |
| 265 | } |
| 266 | |
| 267 | /// Adopt a freshly-decoded project (from `ProjectDocument.read`). Heals |
| 268 | /// media cache keys, ensures a track exists, resets undo/selection, and |
| 269 | /// starts clean (not dirty). View state is applied separately by the |
| 270 | /// document from the `.sq` envelope. |
| 271 | func adopt(_ model: ProjectModel) { |
| 272 | var model = model |
| 273 | if model.tracks.isEmpty { _ = model.addTrack() } |
| 274 | for i in model.media.indices { |
| 275 | model.media[i].cacheKey = MediaPipeline.normalizedCacheKey(for: model.media[i]) |
| 276 | } |
| 277 | model.normalizeStoryboards() |
| 278 | project = model |
| 279 | undoStack.removeAll(); redoStack.removeAll(); selection.removeAll() |
| 280 | post(.projectChanged) |
| 281 | post(.documentStateChanged) |
| 282 | } |
| 283 | |
| 284 | /// Drawing strokes bypass `mutate`, so BoardStore calls this to mark the |
| 285 | /// owning document dirty (NSDocument then autosaves the raster into the |
| 286 | /// package). No-op for the headless context. |
| 287 | func noteRasterChanged() { |
| 288 | post(.documentStateChanged) |
| 289 | ctx.document?.updateChangeCount(.changeDone) |
| 290 | } |
| 291 | } |