1import AppKit
2
3extension 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.
20final 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}