312 lines
12 KiB
Swift
312 lines
12 KiB
Swift
|
|
// 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<Float>) -> Color {
|
||
|
|
Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z))
|
||
|
|
}
|
||
|
|
}
|