// AnimationPanelView.swift // Combined animation panel: transport bar + Dope Sheet / Graph Editor toggle. // Designed to slot into the bottom of any workspace layout. import SwiftUI struct AnimationPanelView: View { let animSystem: AnimationSystem @State private var player: AnimationPlayer? @State private var showGraphEditor = false var body: some View { VStack(spacing: 0) { // ── Editor switcher + Toolbar ── editorToolbar Divider().background(Color.white.opacity(0.1)) // ── Dope Sheet or Graph Editor ── Group { if showGraphEditor { GraphEditorView(animSystem: animSystem) } else { DopeSheetView( animSystem: animSystem, player: player ?? AnimationPlayer(system: animSystem) ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) Divider().background(Color.white.opacity(0.1)) // ── Transport controls ── transportBar } .background(Color(red: 0.15, green: 0.15, blue: 0.15)) .onAppear { if player == nil { player = AnimationPlayer(system: animSystem) } } } // MARK: - Editor toolbar (matches Blender's editor type selector) private var editorToolbar: some View { HStack(spacing: 8) { // Editor type switcher Picker("Editor", selection: $showGraphEditor) { Label("Dope Sheet", systemImage: "rectangle.split.3x1") .tag(false) Label("Graph Editor", systemImage: "chart.xyaxis.line") .tag(true) } .pickerStyle(.segmented) .frame(width: 220) Divider().frame(height: 16) if let action = animSystem.activeAction { // Action name HStack(spacing: 4) { Image(systemName: "film") .font(.system(size: 10)) .foregroundStyle(.orange) Text(action.name) .font(.system(size: 11, weight: .medium)) } } Spacer() // Interpolation mode for selected keyframes Menu { ForEach(KeyframeInterpolation.allCases) { interp in Button { setSelectedInterpolation(interp) } label: { Text(interp.rawValue) } } } label: { Label("Interpolation", systemImage: "point.topleft.down.to.point.bottomright.curvepath") .font(.system(size: 10)) } // Handle type for selected keyframes Menu { ForEach(HandleType.allCases) { ht in Button { setSelectedHandleType(ht) } label: { Text(ht.rawValue) } } } label: { Label("Handles", systemImage: "point.bottomleft.forward.to.arrowtriangle.uturn.scurvepath") .font(.system(size: 10)) } // Insert keyframe Button { insertKeyframeAtCurrent() } label: { Image(systemName: "diamond.fill") .font(.system(size: 10)) .foregroundStyle(.yellow) } } .padding(.horizontal, 12) .padding(.vertical, 4) .background(Color(red: 0.17, green: 0.17, blue: 0.17)) } // MARK: - Transport controls (Blender style) private var transportBar: some View { HStack(spacing: 0) { // Left: frame range HStack(spacing: 4) { Text("Start:") .font(.system(size: 9)) .foregroundStyle(.secondary) Text(String(format: "%.0f", animSystem.frameStart)) .font(.system(size: 10, design: .monospaced)) .frame(width: 36) .padding(.horizontal, 4) .padding(.vertical, 2) .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 3)) Text("End:") .font(.system(size: 9)) .foregroundStyle(.secondary) Text(String(format: "%.0f", animSystem.frameEnd)) .font(.system(size: 10, design: .monospaced)) .frame(width: 36) .padding(.horizontal, 4) .padding(.vertical, 2) .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 3)) } .padding(.horizontal, 12) Spacer() // Center: transport buttons HStack(spacing: 2) { transportButton(icon: "backward.end.fill") { player?.jumpToStart() } transportButton(icon: "chevron.backward") { player?.previousKeyframe() } transportButton(icon: "backward.frame.fill") { player?.stepBackward() } // Play/Pause (larger, highlighted) Button { player?.togglePlayback() } label: { Image(systemName: animSystem.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 14)) .frame(width: 36, height: 28) .background( animSystem.isPlaying ? Color(red: 0.30, green: 0.55, blue: 0.95).opacity(0.3) : Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 4) ) .foregroundStyle(animSystem.isPlaying ? .blue : .primary) } .buttonStyle(.plain) transportButton(icon: "forward.frame.fill") { player?.stepForward() } transportButton(icon: "chevron.forward") { player?.nextKeyframe() } transportButton(icon: "forward.end.fill") { player?.jumpToEnd() } } Spacer() // Right: current frame + FPS HStack(spacing: 8) { // Current frame HStack(spacing: 2) { Image(systemName: "film") .font(.system(size: 9)) .foregroundStyle(.secondary) Text(String(format: "%.0f", animSystem.currentFrame)) .font(.system(size: 11, weight: .bold, design: .monospaced)) .foregroundStyle(Color(red: 0.30, green: 0.55, blue: 0.95)) .frame(width: 40) } Divider().frame(height: 16) // FPS HStack(spacing: 2) { Text("\(Int(animSystem.fps))") .font(.system(size: 10, design: .monospaced)) Text("fps") .font(.system(size: 9)) .foregroundStyle(.secondary) } // Loop toggle Button { animSystem.isLooping.toggle() } label: { Image(systemName: "repeat") .font(.system(size: 10)) .foregroundStyle(animSystem.isLooping ? .orange : .secondary) } .buttonStyle(.plain) } .padding(.horizontal, 12) } .padding(.vertical, 6) .background(Color(red: 0.14, green: 0.14, blue: 0.14)) } private func transportButton(icon: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 10)) .frame(width: 28, height: 28) .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(.secondary) } // MARK: - Actions private func setSelectedInterpolation(_ interp: KeyframeInterpolation) { guard let action = animSystem.activeAction else { return } for track in action.tracks { for kf in track.keyframes where kf.isSelected { kf.interpolation = interp } } } private func setSelectedHandleType(_ ht: HandleType) { guard let action = animSystem.activeAction else { return } for track in action.tracks { for kf in track.keyframes where kf.isSelected { kf.handleType = ht } track.recomputeAutoHandles() } } private func insertKeyframeAtCurrent() { guard let action = animSystem.activeAction else { return } let frame = animSystem.currentFrame for track in action.tracks { let currentVal = track.evaluate(at: frame) track.insertKeyframe(frame: frame, value: currentVal) } } }