// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor { #if UNITY_EDITOR using System; using System.Collections.Generic; using UnityEditor; using UnityEngine; using WallstopStudios.UnityHelpers.Utils; /// /// Holds the mutable state for so we can /// load, edit, and diff animation events without relying on IMGUI. /// internal sealed class AnimationEventEditorViewModel { private const float SwapTimeThreshold = 0.001f; private readonly List _events = new(); private readonly List _baseline = new(); private readonly List _referenceCurve = new(); private readonly List _clipFilterBuffer = new(); public AnimationClip CurrentClip { get; private set; } public IReadOnlyList Events => _events; public IReadOnlyList ReferenceCurve => _referenceCurve; public float FrameRate { get; private set; } public bool FrameRateChanged { get; private set; } public int Count => _events.Count; public void LoadClip(AnimationClip clip) { CurrentClip = clip; FrameRateChanged = false; _events.Clear(); _baseline.Clear(); _referenceCurve.Clear(); if (clip == null) { FrameRate = 0f; return; } FrameRate = clip.frameRate; AnimationEvent[] events = clip.events ?? Array.Empty(); for (int i = 0; i < events.Length; i++) { AnimationEvent existing = events[i]; _events.Add(new AnimationEventItem(existing) { originalIndex = i }); _baseline.Add(AnimationEventEqualityComparer.Instance.Copy(existing)); } ObjectReferenceKeyframe[] curve = AnimationUtility.GetObjectReferenceCurve( clip, EditorCurveBinding.PPtrCurve(string.Empty, typeof(SpriteRenderer), "m_Sprite") ); if (curve != null) { _referenceCurve.AddRange(curve); } _referenceCurve.Sort( static (lhs, rhs) => { int comparison = lhs.time.CompareTo(rhs.time); if (comparison != 0) { return comparison; } string lhsName = lhs.value == null ? string.Empty : lhs.value.name ?? string.Empty; string rhsName = rhs.value == null ? string.Empty : rhs.value.name ?? string.Empty; return string.Compare(lhsName, rhsName, StringComparison.OrdinalIgnoreCase); } ); } public AnimationEventItem GetEvent(int index) { return _events[index]; } public IReadOnlyList FilterClips( AnimationClip[] clips, string searchTerm, AnimationClip currentSelection ) { _clipFilterBuffer.Clear(); if (clips == null || clips.Length == 0) { return _clipFilterBuffer; } string normalizedSearch = string.IsNullOrWhiteSpace(searchTerm) ? string.Empty : searchTerm.Trim(); if (normalizedSearch.Length == 0 || normalizedSearch == "*") { _clipFilterBuffer.AddRange(clips); return _clipFilterBuffer; } string[] tokens = normalizedSearch.Split(' '); using PooledResource> searchTermsResource = Buffers.List.Get( out List searchTerms ); { for (int i = 0; i < tokens.Length; i++) { string token = tokens[i]; if (string.IsNullOrEmpty(token)) { continue; } token = token.Trim(); if (token.Length == 0 || token == "*") { continue; } searchTerms.Add(token.ToLowerInvariant()); } if (searchTerms.Count == 0) { _clipFilterBuffer.AddRange(clips); return _clipFilterBuffer; } for (int ci = 0; ci < clips.Length; ci++) { AnimationClip clip = clips[ci]; if (clip == null) { continue; } bool matches = true; string lowerName = clip.name != null ? clip.name.ToLowerInvariant() : string.Empty; for (int ti = 0; ti < searchTerms.Count; ti++) { if (lowerName.IndexOf(searchTerms[ti], StringComparison.Ordinal) < 0) { matches = false; break; } } if (matches || clip == currentSelection) { _clipFilterBuffer.Add(clip); } } } return _clipFilterBuffer; } public AnimationEventItem AddEvent(float time) { AnimationEventItem item = new(new AnimationEvent { time = time }); _events.Add(item); return item; } public AnimationEventItem DuplicateEvent(int index) { AnimationEventItem source = _events[index]; AnimationEvent duplicate = AnimationEventEqualityComparer.Instance.Copy( source.animationEvent ); AnimationEventItem item = new(duplicate); _events.Insert(index + 1, item); return item; } public void InsertEvent(int index, AnimationEventItem item) { _events.Insert(index, item); } public void RemoveEventAt(int index) { _events.RemoveAt(index); } public bool RemoveEvent(AnimationEventItem item) { return _events.Remove(item); } public void SortEvents(Comparison comparison) { _events.Sort(comparison); } public bool CanSwapWithPrevious(int index) { if (index <= 0 || index >= _events.Count) { return false; } return AreTimesEquivalent(_events[index - 1], _events[index]); } public bool CanSwapWithNext(int index) { if (index < 0 || index >= _events.Count - 1) { return false; } return AreTimesEquivalent(_events[index], _events[index + 1]); } public void MoveEvent(int fromIndex, int toIndex) { if (fromIndex < 0 || fromIndex >= _events.Count) { throw new ArgumentOutOfRangeException(nameof(fromIndex)); } int clampedTarget = Mathf.Clamp(toIndex, 0, _events.Count); if (fromIndex == clampedTarget) { return; } AnimationEventItem item = _events[fromIndex]; _events.RemoveAt(fromIndex); if (clampedTarget > fromIndex) { clampedTarget--; } clampedTarget = Mathf.Clamp(clampedTarget, 0, _events.Count); _events.Insert(clampedTarget, item); } public bool TrySwapWithPrevious(int index) { if (!CanSwapWithPrevious(index)) { return false; } Swap(index, index - 1); return true; } public bool TrySwapWithNext(int index) { if (!CanSwapWithNext(index)) { return false; } Swap(index, index + 1); return true; } public bool TryResetToBaseline(AnimationEventItem item) { if (item?.originalIndex is not int originalIndex) { return false; } if (!TryGetBaseline(originalIndex, out AnimationEvent baseline)) { return false; } AnimationEventEqualityComparer.Instance.CopyInto(item.animationEvent, baseline); return true; } public bool TryGetBaseline(int originalIndex, out AnimationEvent animationEvent) { if (originalIndex < 0 || originalIndex >= _baseline.Count) { animationEvent = null; return false; } animationEvent = _baseline[originalIndex]; return animationEvent != null; } public bool HasPendingChanges() { if (FrameRateChanged) { return true; } if (_events.Count != _baseline.Count) { return true; } for (int i = 0; i < _events.Count; i++) { if ( !AnimationEventEqualityComparer.Instance.Equals( _baseline[i], _events[i].animationEvent ) ) { return true; } } return false; } public bool NeedsReordering() { for (int i = 1; i < _events.Count; i++) { AnimationEvent previous = _events[i - 1].animationEvent; AnimationEvent current = _events[i].animationEvent; if (AnimationEventEqualityComparer.Instance.Compare(previous, current) > 0) { return true; } } return false; } public AnimationEvent[] BuildEventArray() { AnimationEvent[] arr = new AnimationEvent[_events.Count]; for (int i = 0; i < _events.Count; i++) { arr[i] = _events[i].animationEvent; } return arr; } public void SnapshotBaseline() { _baseline.Clear(); for (int i = 0; i < _events.Count; i++) { _baseline.Add( AnimationEventEqualityComparer.Instance.Copy(_events[i].animationEvent) ); } } public void SetFrameRate(float newFrameRate) { if (Mathf.Approximately(FrameRate, newFrameRate)) { return; } FrameRate = newFrameRate; FrameRateChanged = true; } public void ResetFrameRateChanged() { FrameRateChanged = false; } private static bool AreTimesEquivalent(AnimationEventItem lhs, AnimationEventItem rhs) { return Mathf.Abs(lhs.animationEvent.time - rhs.animationEvent.time) < SwapTimeThreshold; } private void Swap(int lhsIndex, int rhsIndex) { AnimationEventItem tmp = _events[lhsIndex]; _events[lhsIndex] = _events[rhsIndex]; _events[rhsIndex] = tmp; } } #endif }