// Animancer // https://kybernetik.com.au/animancer // Copyright 2018-2024 Kybernetik // // FlexiMotion // https://kybernetik.com.au/flexi-motion // Copyright 2023-2024 Kybernetik // #pragma warning disable CS0649 // Field is never assigned to, and will always have its default value. #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEditor.PackageManager.UI; using UnityEngine; // Shared File Last Modified: 2024-07-13 namespace Animancer.Editor // namespace FlexiMotion.Editor { /// [Editor-Only] A welcome screen for an asset. /// https://kybernetik.com.au/animancer/api/Animancer.Editor/ReadMe /// https://kybernetik.com.au/flexi-motion/api/FlexiMotion.Editor/ReadMe /// public abstract class ReadMe : ScriptableObject { /************************************************************************************************************************/ #region Fields and Properties /************************************************************************************************************************/ /// The release ID of the current version. public abstract int ReleaseNumber { get; } /// The display name of this product version. public abstract string VersionName { get; } /// The key used to save the release number. public abstract string PrefKey { get; } /// An introductory explanation of this asset. public virtual string Introduction => null; /// The base name of this product (without any "Lite", "Pro", "Demo", etc.). public abstract string BaseProductName { get; } /// The name of this product. public virtual string ProductName => BaseProductName; /// The display name for the samples section. public virtual string SamplesLabel => "Samples"; /// The URL for the documentation. public abstract string DocumentationURL { get; } /// The URL for the change log of this version. public abstract string ChangeLogURL { get; } /// The URL for the sample documentation. public abstract string SamplesURL { get; } /// The URL to check for the latest version. public virtual string UpdateURL => null; /************************************************************************************************************************/ /// /// The file name ends with the to detect if the user imported /// this version without deleting a previous version. /// /// /// When Unity's package importer sees an existing file with the same GUID as one in the package, it will /// overwrite that file but not move or rename it if the name has changed. So it will leave the file there with /// the old version name. /// private bool HasCorrectName => name.EndsWith(VersionName); /************************************************************************************************************************/ /// Sections to be displayed below the samples. public LinkSection[] LinkSections { get; set; } /// Extra sections to be displayed with the samples. public LinkSection[] ExtraSamples { get; set; } /************************************************************************************************************************/ /// Creates a new and sets the . public ReadMe(params LinkSection[] linkSections) { LinkSections = linkSections; _CheckForUpdatesKey = $"{PrefKey}.{nameof(CheckForUpdates)}"; } /************************************************************************************************************************/ /// A heading with a link to be displayed in the Inspector. public class LinkSection { /************************************************************************************************************************/ /// The main label. public readonly string Heading; /// A short description to be displayed near the . public readonly string Description; /// A link that can be opened by clicking the . public readonly string URL; /// An optional user-friendly version of the . public readonly string DisplayURL; /************************************************************************************************************************/ /// Creates a new . public LinkSection(string heading, string description, string url, string displayURL = null) { Heading = heading; Description = description; URL = url; DisplayURL = displayURL; } /************************************************************************************************************************/ } /************************************************************************************************************************/ /// Returns a mailto link. public static string GetEmailURL(string address, string subject) => $"mailto:{address}?subject={subject.Replace(" ", "%20")}"; /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Show On Startup and Check for Updates /************************************************************************************************************************/ private const string PrefPrefix = nameof(ReadMe) + "."; [SerializeField] private bool _DontShowOnStartup; [NonSerialized] private string _CheckForUpdatesKey; [NonSerialized] private bool _NewVersionAvailable; [NonSerialized] private string _UpdateCheckFailureMessage; [NonSerialized] private string _LatestVersionName; [NonSerialized] private string _LatestVersionChangeLogURL; [NonSerialized] private int _LatestVersionNumber; #if UNITY_WEB_REQUEST [NonSerialized] private bool _CheckedForUpdates; #endif private bool CheckForUpdates { get => EditorPrefs.GetBool(_CheckForUpdatesKey, true); set => EditorPrefs.SetBool(_CheckForUpdatesKey, value); } /************************************************************************************************************************/ private static readonly Dictionary TypeToUpdateCheck = new(); static ReadMe() { AssemblyReloadEvents.beforeAssemblyReload += () => { foreach (var webRequest in TypeToUpdateCheck.Values) webRequest.Dispose(); TypeToUpdateCheck.Clear(); }; } /************************************************************************************************************************/ /// Automatically checks for updates and selects a on startup. [InitializeOnLoadMethod] private static void ShowReadMe() { EditorApplication.delayCall += () => { var instances = FindInstances(out var autoSelect); for (int i = 0; i < instances.Count; i++) instances[i].StartCheckForUpdates(); // Delay the call again to ensure that the Project window actually shows the selection. if (autoSelect != null) EditorApplication.delayCall += () => Selection.activeObject = autoSelect; }; } /************************************************************************************************************************/ /// /// Finds the most recently modified asset with disabled. /// private static List FindInstances(out ReadMe autoSelect) { var instances = new List(); DateTime latestWriteTime = default; autoSelect = null; string autoSelectGUID = null; var guids = AssetDatabase.FindAssets($"t:{nameof(ReadMe)}"); for (int i = 0; i < guids.Length; i++) { var guid = guids[i]; var assetPath = AssetDatabase.GUIDToAssetPath(guid); var asset = AssetDatabase.LoadAssetAtPath(assetPath); if (asset == null) continue; instances.Add(asset); if (asset._DontShowOnStartup && asset.HasCorrectName) continue; // Check if already shown since opening the Unity Editor. if (SessionState.GetBool(PrefPrefix + guid, false)) continue; var writeTime = File.GetLastWriteTimeUtc(assetPath); if (latestWriteTime < writeTime) { latestWriteTime = writeTime; autoSelect = asset; autoSelectGUID = guid; } } if (autoSelectGUID != null) SessionState.SetBool(PrefPrefix + autoSelectGUID, true); return instances; } /************************************************************************************************************************/ /// Called after this object is loaded. protected virtual void OnEnable() { var name = GetType().FullName; var updateText = SessionState.GetString(PrefPrefix + name, ""); OnUpdateCheckComplete(updateText, false); } /************************************************************************************************************************/ private void StartCheckForUpdates() { #if UNITY_WEB_REQUEST if (!CheckForUpdates || _CheckedForUpdates) return; var type = GetType(); if (TypeToUpdateCheck.ContainsKey(type)) return; var url = UpdateURL; if (string.IsNullOrEmpty(url)) return; _CheckedForUpdates = true; var webRequest = UnityEngine.Networking.UnityWebRequest.Get(url); TypeToUpdateCheck.Add(type, webRequest); webRequest.SendWebRequest().completed += _ => { var name = type.FullName; if (webRequest.result == UnityEngine.Networking.UnityWebRequest.Result.Success) { var text = webRequest.downloadHandler.text; OnUpdateCheckComplete(text, true); SessionState.SetString(PrefPrefix + name, text); } else { _UpdateCheckFailureMessage = $"Update check failed: {webRequest.error}."; SessionState.SetString(PrefPrefix + name, ""); } TypeToUpdateCheck.Remove(type); webRequest.Dispose(); }; #endif } /************************************************************************************************************************/ private void OnUpdateCheckComplete(string text, bool log) { #if UNITY_WEB_REQUEST if (string.IsNullOrEmpty(text)) return; _CheckedForUpdates = true; var lines = text.Split('\n'); if (lines.Length < 3) { _UpdateCheckFailureMessage = "Update check failed: text is malformed:\n" + text; return; } int.TryParse(lines[0], out _LatestVersionNumber); _LatestVersionName = lines[1].Trim(); _LatestVersionChangeLogURL = $"{DocumentationURL}/{lines[2].Trim()}"; if (ReleaseNumber >= _LatestVersionNumber) return; _NewVersionAvailable = true; if (log) Debug.Log($"{_LatestVersionName} is now available." + $"\n• Change Log: {_LatestVersionChangeLogURL}" + $"\n• This check can be disabled in the Read Me asset's Inspector.", this); Selection.activeObject = this; #endif } #endregion /************************************************************************************************************************/ #region Custom Editor /************************************************************************************************************************/ /// [Editor-Only] A custom Inspector for . [CustomEditor(typeof(ReadMe), editorForChildClasses: true)] public class Editor : UnityEditor.Editor { /************************************************************************************************************************/ private static readonly GUIContent GUIContent = new(); private static GUIContent TempContent(string text, string tooltip = null) { GUIContent.text = text; GUIContent.tooltip = tooltip; return GUIContent; } /************************************************************************************************************************/ [NonSerialized] private ReadMe _Target; [NonSerialized] private Texture2D _Icon; [NonSerialized] private string _ReleaseNumberPrefKey; [NonSerialized] private int _PreviousVersion; [NonSerialized] private IEnumerable _Samples; [NonSerialized] private string _Title; [NonSerialized] private SerializedProperty _DontShowOnStartupProperty; /// The being edited. public ReadMe Target => _Target; /************************************************************************************************************************/ /// Don't use any margins. public override bool UseDefaultMargins() => false; /************************************************************************************************************************/ protected virtual void OnEnable() { _Target = (ReadMe)target; _Icon = AssetPreview.GetMiniThumbnail(target); _ReleaseNumberPrefKey = _Target.PrefKey + "." + nameof(_Target.ReleaseNumber); _PreviousVersion = PlayerPrefs.GetInt(_ReleaseNumberPrefKey, -1); _Title = $"{_Target.ProductName}\n{_Target.VersionName}"; _DontShowOnStartupProperty = serializedObject.FindProperty(nameof(_DontShowOnStartup)); if (!string.IsNullOrEmpty(_Target.SamplesLabel)) { var assetPath = AssetDatabase.GetAssetPath(_Target); var package = UnityEditor.PackageManager.PackageInfo.FindForAssetPath(assetPath); if (package != null) { try { _Samples = Sample.FindByPackage(package.name, ""); } catch { }// Unity sometimes throws an exception here. Not sure why. } } } /************************************************************************************************************************/ protected override void OnHeaderGUI() { GUILayout.BeginHorizontal(Styles.TitleArea); { var title = TempContent(_Title); var iconWidth = Styles.Title.CalcHeight(title, EditorGUIUtility.currentViewWidth); GUILayout.Label(_Icon, GUILayout.Width(iconWidth), GUILayout.Height(iconWidth)); GUILayout.Label(title, Styles.Title); } GUILayout.EndHorizontal(); } /************************************************************************************************************************/ /// public override void OnInspectorGUI() { serializedObject.Update(); DoIntroduction(); DoSpace(); DoWarnings(); DoNewVersionDetails(); DoCheckForUpdates(); DoShowOnStartup(); DoSpace(); DoIntroductionBlock(); DoSpace(); DoSampleBlock(); DoSpace(); DoSupportBlock(); DoSpace(); DoCheckForUpdates(); DoShowOnStartup(); serializedObject.ApplyModifiedProperties(); } /************************************************************************************************************************/ /// Draws a GUI space 20% of the height of a standard line. protected static void DoSpace() => GUILayout.Space(EditorGUIUtility.singleLineHeight * 0.2f); /************************************************************************************************************************/ /// Draws the if it isn't null. protected virtual void DoIntroduction() { var introduction = _Target.Introduction; if (introduction == null) return; DoSpace(); GUILayout.Label(introduction, EditorStyles.wordWrappedLabel); } /************************************************************************************************************************/ /// Draws a message indicating whether a new version is available. protected virtual void DoNewVersionDetails() { if (_Target._UpdateCheckFailureMessage != null) { EditorGUILayout.HelpBox(_Target._UpdateCheckFailureMessage, MessageType.Info); return; } if (_Target._LatestVersionName == null || _Target._LatestVersionChangeLogURL == null) return; var message = _Target._NewVersionAvailable ? $"{_Target._LatestVersionName} is now available.\nClick here to view the Change Log." : $"{_Target.BaseProductName} is up to date."; EditorGUILayout.HelpBox(message, MessageType.Info); if (TryUseClickEventInLastRect()) Application.OpenURL(_Target._LatestVersionChangeLogURL); } /************************************************************************************************************************/ /// Draws a toggle to disable automatic update checks. protected virtual void DoCheckForUpdates() { #if UNITY_WEB_REQUEST if (string.IsNullOrEmpty(_Target.UpdateURL)) return; var area = GUILayoutUtility.GetRect(0, EditorGUIUtility.singleLineHeight); area.xMin += EditorGUIUtility.singleLineHeight * 0.2f; EditorGUI.BeginChangeCheck(); var value = GUI.Toggle(area, _Target.CheckForUpdates, "Check For Updates"); if (EditorGUI.EndChangeCheck()) { _Target.CheckForUpdates = value; if (value) _Target.StartCheckForUpdates(); } #endif } /************************************************************************************************************************/ /// Draws a toggle to disable automatically selecting the on startup. protected virtual void DoShowOnStartup() { var area = GUILayoutUtility.GetRect(0, EditorGUIUtility.singleLineHeight); area.xMin += EditorGUIUtility.singleLineHeight * 0.2f; var content = TempContent(_DontShowOnStartupProperty.displayName, _DontShowOnStartupProperty.tooltip); var label = EditorGUI.BeginProperty(area, content, _DontShowOnStartupProperty); EditorGUI.BeginChangeCheck(); var value = _DontShowOnStartupProperty.boolValue; value = GUI.Toggle(area, value, label); if (EditorGUI.EndChangeCheck()) { _DontShowOnStartupProperty.boolValue = value; if (value) PlayerPrefs.SetInt(_ReleaseNumberPrefKey, _Target.ReleaseNumber); } EditorGUI.EndProperty(); } /************************************************************************************************************************/ /// Draws warnings about deleting older versions of the product. protected virtual void DoWarnings() { MessageType messageType; if (!_Target.HasCorrectName) { messageType = MessageType.Error; } else if (_PreviousVersion >= 0 && _PreviousVersion < _Target.ReleaseNumber) { messageType = MessageType.Warning; } else return; // Upgraded from any older version. DoSpace(); var directory = AssetDatabase.GetAssetPath(_Target); if (string.IsNullOrEmpty(directory)) return; directory = Path.GetDirectoryName(directory); var productName = _Target.ProductName; string versionWarning; if (messageType == MessageType.Error) { versionWarning = $"You must fully delete any old version of {productName} before importing a new version." + $"\n1. Check the Upgrade Guide in the Change Log." + $"\n2. Click here to delete '{directory}'." + $"\n3. Import {productName} again."; } else { versionWarning = $"You must fully delete any old version of {productName} before importing a new version." + $"\n1. Ignore this message if you have already deleted the old version." + $"\n2. Check the Upgrade Guide in the Change Log." + $"\n3. Click here to delete '{directory}'." + $"\n4. Import {productName} again."; } EditorGUILayout.HelpBox(versionWarning, messageType); CheckDeleteDirectory(directory); DoSpace(); } /************************************************************************************************************************/ /// Asks if the user wants to delete the `directory` and does so if they confirm. private void CheckDeleteDirectory(string directory) { if (!TryUseClickEventInLastRect()) return; var name = _Target.ProductName; if (!AssetDatabase.IsValidFolder(directory)) { Debug.Log($"{directory} doesn't exist." + $" You must have moved {name} somewhere else so you will need to delete it manually.", this); return; } if (!EditorUtility.DisplayDialog($"Delete {name}? ", $"Would you like to delete {directory}?\n\nYou will then need to reimport {name} manually.", "Delete", "Cancel")) return; AssetDatabase.DeleteAsset(directory); } /************************************************************************************************************************/ /// /// Returns true and uses the current event if it is inside the specified /// `area`. /// public static bool TryUseClickEvent(Rect area, int button = -1) { var currentEvent = Event.current; if (currentEvent.type != EventType.MouseUp || (button >= 0 && currentEvent.button != button) || !area.Contains(currentEvent.mousePosition)) return false; GUI.changed = true; GUIUtility.hotControl = 0; currentEvent.Use(); if (currentEvent.button == 2) GUIUtility.keyboardControl = 0; return true; } /// /// Returns true and uses the current event if it is inside the last GUI Layout /// that was drawn. /// public static bool TryUseClickEventInLastRect(int button = -1) => TryUseClickEvent(GUILayoutUtility.GetLastRect(), button); /************************************************************************************************************************/ protected virtual void DoIntroductionBlock() { GUILayout.BeginVertical(Styles.Block); DoHeadingLink("Documentation", null, _Target.DocumentationURL); DoSpace(); DoHeadingLink("Change Log", null, _Target.ChangeLogURL, fontSize: GUI.skin.label.fontSize); GUILayout.EndVertical(); } /************************************************************************************************************************/ protected virtual void DoSampleBlock() { var label = _Target.SamplesLabel; if (string.IsNullOrEmpty(label)) return; GUILayout.BeginVertical(Styles.Block); DoHeadingLink(label, null, _Target.SamplesURL); if (_Samples != null) { foreach (var sample in _Samples) { if (sample.isImported) { try { var path = Path.GetRelativePath(Environment.CurrentDirectory, sample.importPath); var folder = AssetDatabase.LoadAssetAtPath(path); using (new EditorGUI.DisabledScope(true)) EditorGUILayout.ObjectField(GUIContent.none, folder, typeof(DefaultAsset), false); } catch (Exception exception) { if (GUILayout.Button($"{sample.description}: {exception.GetType().Name}")) Debug.LogException(exception); } } else { EditorGUILayout.LabelField(sample.displayName, "Not Imported"); } } var buttonContent = TempContent( "Open Package Manager", "Samples can be imported via the Samples tab in the Package Manager" + "\n\nIt's generally recommended to delete any samples after you're done with them"); if (GUILayout.Button(buttonContent)) Window.Open("Animancer"); } DoExtraSamples(); GUILayout.EndVertical(); } /************************************************************************************************************************/ protected virtual void DoExtraSamples() { if (_Target.ExtraSamples == null) return; for (int i = 0; i < _Target.ExtraSamples.Length; i++) { if (i > 0) DoSpace(); var section = _Target.ExtraSamples[i]; DoHeadingLink( section.Heading, section.Description, section.URL, section.DisplayURL, GUI.skin.label.fontSize); } } /************************************************************************************************************************/ protected virtual void DoSupportBlock() { GUILayout.BeginVertical(Styles.Block); for (int i = 0; i < _Target.LinkSections.Length; i++) { if (i > 0) DoSpace(); var section = _Target.LinkSections[i]; DoHeadingLink( section.Heading, section.Description, section.URL, section.DisplayURL); } GUILayout.EndVertical(); } /************************************************************************************************************************/ /// Draws a headding which acts as a button to open a URL. public static void DoHeadingLink( string heading, string description, string url, string displayURL = null, int fontSize = 22) { // Heading. var style = url == null ? Styles.HeaderLabel : Styles.HeaderLink; var area = DoLinkButton(heading, url, style, fontSize); // Description. area.y += EditorGUIUtility.standardVerticalSpacing; var urlHeight = Styles.URL.fontSize + Styles.URL.margin.vertical; area.height -= urlHeight; if (description != null) GUI.Label(area, description, Styles.Description); // URL. area.y += area.height; area.height = urlHeight; displayURL ??= url; if (displayURL != null) { var content = TempContent(displayURL, "Click to copy this link to the clipboard"); if (GUI.Button(area, content, Styles.URL)) { GUIUtility.systemCopyBuffer = displayURL; Debug.Log($"Copied '{displayURL}' to the clipboard."); } EditorGUIUtility.AddCursorRect(area, MouseCursor.Text); } } /************************************************************************************************************************/ /// Draws a button to open a URL. public static Rect DoLinkButton(string text, string url, GUIStyle style, int fontSize = 22) { var content = TempContent(text, url); style.fontSize = fontSize; var size = style.CalcSize(content); var area = GUILayoutUtility.GetRect(0, size.y); var linkArea = new Rect(area.x, area.y, size.x, area.height); area.xMin += size.x; if (url == null) { GUI.Label(linkArea, content, style); } else { if (GUI.Button(linkArea, content, style)) Application.OpenURL(url); EditorGUIUtility.AddCursorRect(linkArea, MouseCursor.Link); DrawLine( new(linkArea.xMin, linkArea.yMax), new(linkArea.xMax, linkArea.yMax), style.normal.textColor); } return area; } /************************************************************************************************************************/ /// Draws a line between the `start` and `end` using the `color`. public static void DrawLine(Vector2 start, Vector2 end, Color color) { var previousColor = Handles.color; Handles.BeginGUI(); Handles.color = color; Handles.DrawLine(start, end); Handles.color = previousColor; Handles.EndGUI(); } /************************************************************************************************************************/ /// Various s used by the . protected static class Styles { /************************************************************************************************************************/ public static readonly GUIStyle TitleArea = "In BigTitle"; public static readonly GUIStyle Title = new(GUI.skin.label) { fontSize = 26, }; public static readonly GUIStyle Block = GUI.skin.box; public static readonly GUIStyle HeaderLabel = new(GUI.skin.label) { stretchWidth = false, }; public static readonly GUIStyle HeaderLink = new(HeaderLabel); public static readonly GUIStyle Description = new(GUI.skin.label) { alignment = TextAnchor.LowerLeft, }; public static readonly GUIStyle URL = new(GUI.skin.label) { fontSize = 9, alignment = TextAnchor.LowerLeft, }; /************************************************************************************************************************/ static Styles() { HeaderLink.normal.textColor = HeaderLink.hover.textColor = new Color32(0x00, 0x78, 0xDA, 0xFF); URL.normal.textColor = Color.Lerp(URL.normal.textColor, Color.grey, 0.8f); } /************************************************************************************************************************/ } /************************************************************************************************************************/ } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ } } #endif