255 lines
9.1 KiB
Swift
255 lines
9.1 KiB
Swift
|
|
// 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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|