1import AppKit
2
3/// Per-window view/session state: what's hidden, focused, zoomed, which tool is
4/// active, the draw color. Deliberately OUTSIDE `ProjectModel` so undo/redo
5/// never toggles visibility or zoom. Each open document owns one of these
6/// (`ctx.session`), so two windows are fully independent. The portable subset
7/// is saved into the `.sq` envelope as `ViewState` (see `captureViewState`).
8final class SessionState {
9 /// Back-reference to the owning context, for state that reads the model or
10 /// playhead (`panelUnderPlayhead`). Set right after construction.
11 unowned var ctx: DocumentContext!
12
13 var snapping = true { didSet { postViewOptions() } }
14 var showFilmstrips = true { didSet { postViewOptions() } }
15 /// Show the clover-recording transcript as synchronized captions over the
16 /// viewer (only ever visible when a `cam.mov` with a sibling `transcript.json`
17 /// is under the playhead).
18 var subtitlesEnabled = true { didSet { postViewOptions() } }
19 /// Vertical zoom: multiplies the base lane height for all tracks.
20 var laneScale: CGFloat = 1 {
21 didSet {
22 laneScale = min(3, max(0.4, laneScale))
23 postViewOptions()
24 }
25 }
26 /// Per-lane height factor on top of laneScale (drag a lane boundary).
27 var trackHeights: [TrackRef: CGFloat] = [:] { didSet { postViewOptions() } }
28
29 var hiddenTracks: Set<TrackRef> = [] { didSet { postViewOptions() } }
30 var focusedTracks: Set<TrackRef> = [] { didSet { postViewOptions() } }
31 /// The timeline span currently on screen (set by TimelineView as it
32 /// scrolls/zooms). The proxy builder treats it as a heat anchor — the
33 /// user scrubs inside what they can see. Not persisted; no notification
34 /// (read lazily by the chunk scheduler).
35 var visibleTimeRange: ClosedRange<Double>?
36 /// The Fusion comps band gets its own hide/focus.
37 var fusionHidden = false { didSet { postViewOptions() } }
38 var fusionFocus = false { didSet { postViewOptions() } }
39 /// The one pane (a lane, or `.fusion`) blown up large while the rest tile in
40 /// the leftover space — "Priority" mode, à la Google Meet's spotlight.
41 var priorityPane: TrackRef? = nil { didSet { postViewOptions() } }
42 var previewsOnLeft = false
43
44 // Editing tools (per window).
45 var mainTool: MainTool = .select { didSet { postViewOptions() } }
46 var drawColor: NSColor = .black { didSet { postViewOptions() } }
47 /// Armed by the toolbar's Shapes dropdown: the next click-drag on a
48 /// storyboard preview places this shape, then control returns to select.
49 var pendingShape: BoardShape.Kind? { didSet { postViewOptions() } }
50
51 /// Is there a storyboard panel under the playhead to draw on?
52 var panelUnderPlayhead: Clip? {
53 let p = ctx.store.project
54 let t = ctx.playback.playhead
55 return p.clipAt(track: .storyboard, time: t, kind: .storyboard)
56 }
57
58 // MARK: - Track visibility
59
60 /// Drop per-lane session state (hide, focus, custom height) for lanes that
61 /// no longer exist. Without this a deleted FOCUSED track leaves focusedTracks
62 /// non-empty, so the viewer treats focus as active and blanks every
63 /// surviving track. Called on every project change.
64 func reconcileTracks(_ p: ProjectModel) {
65 var live = Set(p.laneRefs)
66 live.insert(.fusion) // the Fusion band always survives
67 let prunedFocus = focusedTracks.intersection(live)
68 let prunedHidden = hiddenTracks.intersection(live)
69 let prunedHeights = trackHeights.filter { live.contains($0.key) }
70 if prunedFocus != focusedTracks { focusedTracks = prunedFocus }
71 if prunedHidden != hiddenTracks { hiddenTracks = prunedHidden }
72 if prunedHeights.count != trackHeights.count { trackHeights = prunedHeights }
73 if let pane = priorityPane, !live.contains(pane) { priorityPane = nil }
74 }
75
76 func toggleHidden(_ ref: TrackRef) {
77 if hiddenTracks.contains(ref) { hiddenTracks.remove(ref) }
78 else { hiddenTracks.insert(ref) }
79 }
80 func toggleFocus(_ ref: TrackRef) {
81 if focusedTracks.contains(ref) { focusedTracks.remove(ref) }
82 else { focusedTracks.insert(ref) }
83 }
84
85 /// Reveal every track/band: clear all hide, focus and priority state.
86 func showAll() {
87 hiddenTracks = []
88 focusedTracks = []
89 fusionHidden = false
90 fusionFocus = false
91 priorityPane = nil
92 }
93
94 /// Lanes whose previews show: focus wins; otherwise everything not hidden.
95 func visibleTracks(_ p: ProjectModel) -> [TrackRef] {
96 let ordered = p.laneRefs
97 let focused = ordered.filter { focusedTracks.contains($0) }
98 if !focused.isEmpty { return focused }
99 return ordered.filter { !hiddenTracks.contains($0) }
100 }
101
102 // MARK: - Portable view state (saved in the .sq envelope)
103
104 /// Snapshot the session view state for persistence.
105 func captureViewState() -> ViewState {
106 var v = ViewState()
107 v.hiddenTracks = Array(hiddenTracks)
108 v.focusedTracks = Array(focusedTracks)
109 v.trackHeights = trackHeights.map { TrackHeight(track: $0.key, factor: Double($0.value)) }
110 v.laneScale = Double(laneScale)
111 v.snapping = snapping
112 v.showFilmstrips = showFilmstrips
113 v.subtitles = subtitlesEnabled
114 v.previewsOnLeft = previewsOnLeft
115 v.priorityPane = priorityPane
116 v.fusionHidden = fusionHidden
117 v.fusionFocus = fusionFocus
118 v.playhead = ctx.playback.playhead
119 return v
120 }
121
122 /// Restore session view state from a loaded project.
123 func apply(_ v: ViewState) {
124 hiddenTracks = Set(v.hiddenTracks)
125 focusedTracks = Set(v.focusedTracks)
126 trackHeights = Dictionary(v.trackHeights.map { ($0.track, CGFloat($0.factor)) },
127 uniquingKeysWith: { a, _ in a })
128 laneScale = CGFloat(v.laneScale)
129 snapping = v.snapping
130 showFilmstrips = v.showFilmstrips
131 subtitlesEnabled = v.subtitles
132 previewsOnLeft = v.previewsOnLeft
133 priorityPane = v.priorityPane
134 fusionHidden = v.fusionHidden
135 fusionFocus = v.fusionFocus
136 if v.playhead > 0 { ctx.playback.seek(to: v.playhead) }
137 }
138
139 private func postViewOptions() {
140 NotificationCenter.default.post(name: .viewOptionsChanged, object: nil)
141 }
142}