// DopeSheetView.swift // Blender-style Dope Sheet with diamond keyframes on horizontal tracks. import SwiftUI struct DopeSheetView: View { let animSystem: AnimationSystem let player: AnimationPlayer // View state @State private var frameOffset: Float = 0 // scroll position in frames @State private var framesPerPoint: Float = 0.25 // zoom level @State private var channelListWidth: CGFloat = 140 @State private var draggedKeyframe: (trackID: UUID, kfID: UUID)? // Blender colors private let bgColor = Color(red: 0.16, green: 0.16, blue: 0.16) private let channelBg = Color(red: 0.20, green: 0.20, blue: 0.20) private let channelBgAlt = Color(red: 0.18, green: 0.18, blue: 0.18) private let gridLineColor = Color.white.opacity(0.06) private let gridTextColor = Color.white.opacity(0.35) private let playheadColor = Color(red: 0.30, green: 0.55, blue: 0.95) private let keyframeColor = Color(red: 0.90, green: 0.78, blue: 0.30) private let keyframeSelected = Color.white private let headerBg = Color(red: 0.14, green: 0.14, blue: 0.14) private let trackHeight: CGFloat = 22 private let headerHeight: CGFloat = 28 var body: some View { GeometryReader { geo in let totalWidth = geo.size.width let timelineWidth = totalWidth - channelListWidth VStack(spacing: 0) { // ── Frame number header ── HStack(spacing: 0) { // Channel list header Text("Channels") .font(.system(size: 10, weight: .medium)) .foregroundStyle(.secondary) .frame(width: channelListWidth, height: headerHeight) .background(headerBg) // Frame numbers ruler frameRuler(width: timelineWidth) .frame(height: headerHeight) .background(headerBg) } Divider().background(Color.white.opacity(0.1)) // ── Tracks ── ScrollView(.vertical, showsIndicators: false) { if let action = animSystem.activeAction { LazyVStack(spacing: 0) { // Summary track summaryTrack(action: action, width: timelineWidth) // Per-channel tracks ForEach(Array(action.tracks.enumerated()), id: \.element.id) { idx, track in channelTrack( track: track, index: idx, width: timelineWidth ) } } } } } .background(bgColor) .gesture(zoomGesture) .gesture(scrollGesture) } } // MARK: - Frame ruler private func frameRuler(width: CGFloat) -> some View { Canvas { context, size in let startFrame = Int(frameOffset) let endFrame = startFrame + Int(Float(width) * framesPerPoint) + 1 // Major ticks every 10 frames, minor every 5 for f in startFrame...endFrame { let x = CGFloat(Float(f) - frameOffset) / CGFloat(framesPerPoint) guard x >= 0 && x <= size.width else { continue } if f % 10 == 0 { // Major tick + label context.stroke( Path { p in p.move(to: .init(x: x, y: size.height - 8)); p.addLine(to: .init(x: x, y: size.height)) }, with: .color(.white.opacity(0.3)), lineWidth: 1 ) context.draw( Text("\(f)").font(.system(size: 9)).foregroundStyle(gridTextColor), at: CGPoint(x: x, y: size.height - 16) ) } else if f % 5 == 0 { context.stroke( Path { p in p.move(to: .init(x: x, y: size.height - 4)); p.addLine(to: .init(x: x, y: size.height)) }, with: .color(.white.opacity(0.15)), lineWidth: 0.5 ) } } // Playhead marker let phX = CGFloat(animSystem.currentFrame - frameOffset) / CGFloat(framesPerPoint) if phX >= 0 && phX <= size.width { // Triangle marker let tri = Path { p in p.move(to: .init(x: phX - 6, y: 0)) p.addLine(to: .init(x: phX + 6, y: 0)) p.addLine(to: .init(x: phX, y: 10)) p.closeSubpath() } context.fill(tri, with: .color(playheadColor)) } } .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in let frame = frameOffset + Float(value.location.x) * framesPerPoint animSystem.currentFrame = max(animSystem.frameStart, min(frame, animSystem.frameEnd)) } ) } // MARK: - Summary track (all keyframes merged) private func summaryTrack(action: AnimAction, width: CGFloat) -> some View { HStack(spacing: 0) { HStack(spacing: 4) { Image(systemName: "chevron.down") .font(.system(size: 8)) Image(systemName: "cube.fill") .font(.system(size: 10)) .foregroundStyle(.orange) Text(action.name) .font(.system(size: 11, weight: .medium)) } .frame(width: channelListWidth, alignment: .leading) .padding(.leading, 8) Canvas { context, size in drawGridLines(context: context, size: size) drawPlayhead(context: context, size: size) // All keyframes as white diamonds let allFrames = Set(action.tracks.flatMap { $0.sortedKeyframes.map(\.frame) }) for frame in allFrames { let x = CGFloat(frame - frameOffset) / CGFloat(framesPerPoint) guard x >= -6 && x <= size.width + 6 else { continue } drawDiamond(context: context, at: CGPoint(x: x, y: size.height / 2), color: keyframeColor, size: 7) } } .frame(width: width, height: trackHeight) .background(channelBg) } .frame(height: trackHeight) } // MARK: - Individual channel track private func channelTrack(track: AnimationTrack, index: Int, width: CGFloat) -> some View { let bg = index % 2 == 0 ? channelBgAlt : channelBg let trackColor = simdToColor(track.color) return HStack(spacing: 0) { // Channel name HStack(spacing: 4) { Rectangle() .fill(trackColor) .frame(width: 3, height: 14) .cornerRadius(1) .padding(.leading, 24) Text(track.displayName) .font(.system(size: 10)) .foregroundStyle(track.isMuted ? .secondary : .primary) Spacer() // Mute / lock buttons Button { track.isMuted.toggle() } label: { Image(systemName: track.isMuted ? "speaker.slash" : "speaker.wave.2") .font(.system(size: 8)) .foregroundStyle(.secondary) } .buttonStyle(.plain) Button { track.isLocked.toggle() } label: { Image(systemName: track.isLocked ? "lock" : "lock.open") .font(.system(size: 8)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .padding(.trailing, 4) } .frame(width: channelListWidth) // Keyframe track Canvas { context, size in drawGridLines(context: context, size: size) drawPlayhead(context: context, size: size) // Draw keyframes as diamonds for kf in track.sortedKeyframes { let x = CGFloat(kf.frame - frameOffset) / CGFloat(framesPerPoint) guard x >= -6 && x <= size.width + 6 else { continue } let color = kf.isSelected ? keyframeSelected : keyframeColor drawDiamond(context: context, at: CGPoint(x: x, y: size.height / 2), color: color, size: 5) } } .frame(width: width, height: trackHeight) .background(bg) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in let frame = frameOffset + Float(value.location.x) * framesPerPoint // Find nearest keyframe to select/drag if let nearest = track.keyframes.min(by: { abs($0.frame - frame) < abs($1.frame - frame) }), abs(nearest.frame - frame) < 3 * framesPerPoint { if draggedKeyframe == nil { // Select for kf in track.keyframes { kf.isSelected = false } nearest.isSelected = true draggedKeyframe = (track.id, nearest.id) } // Drag nearest.frame = round(frame) // snap to integer frames } } .onEnded { _ in draggedKeyframe = nil track.recomputeAutoHandles() } ) } .frame(height: trackHeight) } // MARK: - Drawing helpers private func drawGridLines(context: GraphicsContext, size: CGSize) { let startFrame = Int(frameOffset) let endFrame = startFrame + Int(Float(size.width) * framesPerPoint) + 1 for f in startFrame...endFrame where f % 10 == 0 { let x = CGFloat(Float(f) - frameOffset) / CGFloat(framesPerPoint) context.stroke( Path { p in p.move(to: .init(x: x, y: 0)); p.addLine(to: .init(x: x, y: size.height)) }, with: .color(gridLineColor), lineWidth: 0.5 ) } } private func drawPlayhead(context: GraphicsContext, size: CGSize) { let x = CGFloat(animSystem.currentFrame - frameOffset) / CGFloat(framesPerPoint) guard x >= 0 && x <= size.width else { return } context.stroke( Path { p in p.move(to: .init(x: x, y: 0)); p.addLine(to: .init(x: x, y: size.height)) }, with: .color(playheadColor), lineWidth: 1.5 ) } private func drawDiamond(context: GraphicsContext, at center: CGPoint, color: Color, size: CGFloat) { let diamond = Path { p in p.move(to: .init(x: center.x, y: center.y - size)) p.addLine(to: .init(x: center.x + size, y: center.y)) p.addLine(to: .init(x: center.x, y: center.y + size)) p.addLine(to: .init(x: center.x - size, y: center.y)) p.closeSubpath() } context.fill(diamond, with: .color(color)) context.stroke(diamond, with: .color(.black.opacity(0.5)), lineWidth: 0.5) } // MARK: - Gestures private var zoomGesture: some Gesture { MagnifyGesture() .onChanged { value in framesPerPoint = max(0.05, min(framesPerPoint / Float(value.magnification), 2.0)) } } private var scrollGesture: some Gesture { DragGesture(minimumDistance: 10) .onChanged { value in if abs(value.translation.width) > abs(value.translation.height) { frameOffset -= Float(value.velocity.width) * framesPerPoint * 0.005 frameOffset = max(0, frameOffset) } } } private func simdToColor(_ c: SIMD4) -> Color { Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z)) } }