diff --git a/CHANGELOG.md b/CHANGELOG.md index 231a5c6e..31c6fcc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- [#244] - Added a framework that can be used to override the rendering of an object without modifying the object itself +- [#244] - Added a framework for observing scene object changes and reacting to them. +- [#244] - Added `SelfDestructComponent` (useful for hidden preview-only components) ### Fixed diff --git a/Dependencies~/0Harmony.LICENSE.txt b/Dependencies~/0Harmony.LICENSE.txt new file mode 100644 index 00000000..655c71c5 --- /dev/null +++ b/Dependencies~/0Harmony.LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Andreas Pardeike + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Dependencies~/0Harmony.LICENSE.txt.meta b/Dependencies~/0Harmony.LICENSE.txt.meta new file mode 100644 index 00000000..4e5e7ff8 --- /dev/null +++ b/Dependencies~/0Harmony.LICENSE.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bbc8bb0ffc3ca7643ad5cc02c086ce59 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Dependencies~/0Harmony.dll b/Dependencies~/0Harmony.dll new file mode 100644 index 00000000..6b7d29da Binary files /dev/null and b/Dependencies~/0Harmony.dll differ diff --git a/Dependencies~/0Harmony.dll.meta b/Dependencies~/0Harmony.dll.meta new file mode 100644 index 00000000..43b7ee20 --- /dev/null +++ b/Dependencies~/0Harmony.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 70b516e8a0736c04391d501bcbbb2f03 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Any: + second: + enabled: 1 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/API/Fluent/Sequence/Sequence.cs b/Editor/API/Fluent/Sequence/Sequence.cs index d99ceddf..1eebdfb4 100644 --- a/Editor/API/Fluent/Sequence/Sequence.cs +++ b/Editor/API/Fluent/Sequence/Sequence.cs @@ -1,8 +1,10 @@ #region +using System; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using nadena.dev.ndmf.model; +using nadena.dev.ndmf.preview; #endregion @@ -50,6 +52,18 @@ internal DeclaringPass(SolverPass pass, SolverContext solverContext, BuildPhase _seq = seq; } + public DeclaringPass PreviewingWith(IRenderFilter filter) + { + if (_pass.RenderFilter != null) + { + throw new InvalidOperationException("Render filter already set"); + } + + _pass.RenderFilter = filter; + + return this; + } + /// /// Declares that the pass you just declared must run before a particular other plugin. /// diff --git a/Editor/API/Model/SolverPass.cs b/Editor/API/Model/SolverPass.cs index 3a5a1733..e8a2de2d 100644 --- a/Editor/API/Model/SolverPass.cs +++ b/Editor/API/Model/SolverPass.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Immutable; +using nadena.dev.ndmf.preview; #endregion @@ -22,6 +23,7 @@ internal class SolverPass internal IImmutableSet RequiredExtensions { get; set; } internal IImmutableSet CompatibleExtensions { get; set; } + internal IRenderFilter RenderFilter { get; set; } internal bool IsExtensionCompatible(Type ty) { diff --git a/Editor/API/Solver/PluginResolver.cs b/Editor/API/Solver/PluginResolver.cs index bce346bf..5f5eeb12 100644 --- a/Editor/API/Solver/PluginResolver.cs +++ b/Editor/API/Solver/PluginResolver.cs @@ -5,7 +5,7 @@ using System.Collections.Immutable; using System.Linq; using nadena.dev.ndmf.model; -using UnityEngine; +using nadena.dev.ndmf.preview; #endregion @@ -49,6 +49,7 @@ internal ConcretePass(IPluginInternal plugin, IPass pass, ImmutableList de internal class PluginResolver { + internal PreviewSession PreviewSession { get; private set; } internal ImmutableList<(BuildPhase, IList)> Passes { get; } public PluginResolver() : this( @@ -70,6 +71,8 @@ public PluginResolver(IEnumerable plugins) : this( public PluginResolver(IEnumerable pluginTemplates) { + PreviewSession = new PreviewSession(); + var solverContext = new SolverContext(); foreach (var plugin in pluginTemplates) @@ -195,6 +198,11 @@ ImmutableList ToConcretePasses(BuildPhase phase, IEnumerable 0) diff --git a/Editor/ChangeStream.meta b/Editor/ChangeStream.meta new file mode 100644 index 00000000..f6b1c2fd --- /dev/null +++ b/Editor/ChangeStream.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 12dce40ae2134d4a97d8046f50093dbd +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/ChangeStream/ChangeStreamMonitor.cs b/Editor/ChangeStream/ChangeStreamMonitor.cs new file mode 100644 index 00000000..71f3ab09 --- /dev/null +++ b/Editor/ChangeStream/ChangeStreamMonitor.cs @@ -0,0 +1,204 @@ +#region + +using System; +using System.Diagnostics; +using UnityEditor; +using UnityEngine.Profiling; +using Debug = UnityEngine.Debug; + +#endregion + +namespace nadena.dev.ndmf.rq.unity.editor +{ + internal class ChangeStreamMonitor + { + [InitializeOnLoadMethod] + static void Init() + { + ObjectChangeEvents.changesPublished += OnChange; + } + + private static void OnChange(ref ObjectChangeEventStream stream) + { + Profiler.BeginSample("ChangeStreamMonitor.OnChange"); + + int length = stream.length; + for (int i = 0; i < length; i++) + { + try + { + HandleEvent(stream, i); + } + catch (Exception e) + { + Debug.LogError($"Error handling event {i}: {e}"); + } + } + + Profiler.EndSample(); + } + + private static void HandleEvent(ObjectChangeEventStream stream, int i) + { + switch (stream.GetEventType(i)) + { + case ObjectChangeKind.None: break; + + case ObjectChangeKind.ChangeScene: + { + ObjectWatcher.Instance.Hierarchy.InvalidateAll(); + + break; + } + + case ObjectChangeKind.CreateGameObjectHierarchy: + { + stream.GetCreateGameObjectHierarchyEvent(i, out var data); + + ObjectWatcher.Instance.Hierarchy.FireGameObjectCreate(data.instanceId); + break; + } + + case ObjectChangeKind.ChangeGameObjectStructureHierarchy: + { + stream.GetChangeGameObjectStructureHierarchyEvent(i, out var data); + + OnChangeGameObjectStructureHierarchy(data); + + break; + } + + case ObjectChangeKind.ChangeGameObjectStructure: // add/remove components + { + stream.GetChangeGameObjectStructureEvent(i, out var data); + OnChangeGameObjectStructure(data); + + break; + } + + case ObjectChangeKind.ChangeGameObjectParent: + { + stream.GetChangeGameObjectParentEvent(i, out var data); + OnChangeGameObjectParent(data); + + break; + } + + case ObjectChangeKind.ChangeGameObjectOrComponentProperties: + { + stream.GetChangeGameObjectOrComponentPropertiesEvent(i, out var data); + OnChangeGameObjectOrComponentProperties(data); + + break; + } + + case ObjectChangeKind.DestroyGameObjectHierarchy: + { + stream.GetDestroyGameObjectHierarchyEvent(i, out var data); + OnDestroyGameObjectHierarchy(data); + + break; + } + + case ObjectChangeKind.CreateAssetObject: break; + case ObjectChangeKind.DestroyAssetObject: + { + stream.GetDestroyAssetObjectEvent(i, out var data); + OnDestroyAssetObject(data); + + break; + } + + case ObjectChangeKind.ChangeAssetObjectProperties: + { + stream.GetChangeAssetObjectPropertiesEvent(i, out var data); + OnChangeAssetObjectProperties(data); + + break; + } + + case ObjectChangeKind.UpdatePrefabInstances: + { + stream.GetUpdatePrefabInstancesEvent(i, out var data); + OnUpdatePrefabInstances(data); + + break; + } + + case ObjectChangeKind.ChangeChildrenOrder: + { + stream.GetChangeChildrenOrderEvent(i, out var data); + OnChangeChildrenOrder(data); + + break; + } + } + } + + private static void OnChangeChildrenOrder(ChangeChildrenOrderEventArgs data) + { + var instanceId = data.instanceId; + + ObjectWatcher.Instance.Hierarchy.FireReorderNotification(instanceId); + } + + private static void OnUpdatePrefabInstances(UpdatePrefabInstancesEventArgs data) + { + foreach (var iid in data.instanceIds) + { + ObjectWatcher.Instance.Hierarchy.InvalidateTree(iid); + } + } + + private static void OnChangeAssetObjectProperties(ChangeAssetObjectPropertiesEventArgs data) + { + var instanceId = data.instanceId; + + ObjectWatcher.Instance.Hierarchy.FireObjectChangeNotification(instanceId); + } + + private static void OnDestroyAssetObject(DestroyAssetObjectEventArgs data) + { + var instanceId = data.instanceId; + + ObjectWatcher.Instance.Hierarchy.InvalidateTree(instanceId); + } + + private static void OnDestroyGameObjectHierarchy(DestroyGameObjectHierarchyEventArgs data) + { + var instanceId = data.instanceId; + + ObjectWatcher.Instance.Hierarchy.InvalidateTree(instanceId); + } + + private static void OnChangeGameObjectOrComponentProperties(ChangeGameObjectOrComponentPropertiesEventArgs data) + { + var instanceId = data.instanceId; + + ObjectWatcher.Instance.Hierarchy.FireObjectChangeNotification(instanceId); + } + + private static void OnChangeGameObjectParent(ChangeGameObjectParentEventArgs data) + { + var instanceId = data.instanceId; + var priorParentId = data.previousParentInstanceId; + var newParentId = data.newParentInstanceId; + + ObjectWatcher.Instance.Hierarchy.FireReparentNotification(instanceId); + } + + private static void OnChangeGameObjectStructure(ChangeGameObjectStructureEventArgs data) + { + var instanceId = data.instanceId; + + ObjectWatcher.Instance.Hierarchy.FireStructureChangeEvent(instanceId); + } + + private static void OnChangeGameObjectStructureHierarchy(ChangeGameObjectStructureHierarchyEventArgs data) + { + var instanceId = data.instanceId; + + ObjectWatcher.Instance.Hierarchy.InvalidateTree(instanceId); + } + } +} \ No newline at end of file diff --git a/Editor/ChangeStream/ChangeStreamMonitor.cs.meta b/Editor/ChangeStream/ChangeStreamMonitor.cs.meta new file mode 100644 index 00000000..2e59064c --- /dev/null +++ b/Editor/ChangeStream/ChangeStreamMonitor.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 71e71a70dcc445e9a424f50ae1908b29 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/ChangeStream/ListenerSet.cs b/Editor/ChangeStream/ListenerSet.cs new file mode 100644 index 00000000..0ee71cb7 --- /dev/null +++ b/Editor/ChangeStream/ListenerSet.cs @@ -0,0 +1,108 @@ +#region + +using System; + +#endregion + +namespace nadena.dev.ndmf.rq.unity.editor +{ + internal class Listener : IDisposable + { + private ListenerSet _owner; + internal Listener _next, _prev; + + private readonly ListenerSet.Invokee _callback; + private readonly WeakReference _param; + + internal Listener( + ListenerSet owner, + ListenerSet.Invokee callback, + object param + ) + { + _owner = owner; + _next = _prev = this; + _callback = callback; + _param = new WeakReference(param); + } + + public void Dispose() + { + if (_next != null) + { + _next._prev = _prev; + _prev._next = _next; + } + + _next = _prev = null; + _param.SetTarget(null); + } + + internal void MaybePrune() + { + if (!_param.TryGetTarget(out _)) + { + Dispose(); + } + } + + // Invoked under lock(_owner) + internal void MaybeFire(T info) + { + if (!_param.TryGetTarget(out var target) || _callback(target, info)) + { + Dispose(); + } + } + } + + internal class ListenerSet + { + public delegate bool Invokee(object target, T info); + + private Listener _head; + + public ListenerSet() + { + _head = new Listener(this, (object _, T _) => false, null); + _head._next = _head._prev = _head; + } + + public bool HasListeners() + { + return _head._next != _head; + } + + public IDisposable Register(Invokee callback, object param) + { + var listener = new Listener(this, callback, param); + + listener._next = _head._next; + listener._prev = _head; + _head._next._prev = listener; + _head._next = listener; + + return listener; + } + + public void Fire(T info) + { + for (var listener = _head._next; listener != _head;) + { + var next = listener._next; + listener.MaybeFire(info); + listener = next; + } + } + + public void Prune() + { + for (var listener = _head._next; listener != _head;) + { + var next = listener._next; + listener.MaybePrune(); + listener = next; + } + } + } +} \ No newline at end of file diff --git a/Editor/ChangeStream/ListenerSet.cs.meta b/Editor/ChangeStream/ListenerSet.cs.meta new file mode 100644 index 00000000..022cc028 --- /dev/null +++ b/Editor/ChangeStream/ListenerSet.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3eaf3d7227b4441b805f1fc4b49f10b3 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/ChangeStream/ObjectWatcher.cs b/Editor/ChangeStream/ObjectWatcher.cs new file mode 100644 index 00000000..2f586fb0 --- /dev/null +++ b/Editor/ChangeStream/ObjectWatcher.cs @@ -0,0 +1,304 @@ +#region + +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using UnityEditor; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +#endregion + +namespace nadena.dev.ndmf.rq.unity.editor +{ + #region + + using UnityObject = Object; + + #endregion + + /// + /// ObjectWatcher provides a high level API for monitoring for various changes to assets and scene objects in the + /// editor. + /// + internal sealed class ObjectWatcher + { + // Supported watch categories: + // - Single-object watch: Monitor asset, component properties, etc + // -> simple mapping + // - Parent watch: Monitor whether the parent of an object changes + // -> record parent path + // - Component search: Monitor the set of components matching a type filter under a given object + // -> + + // Event types: + // - ChangeScene: Fires everything + // - CreateGameObjectHierarchy: Check parents, possibly fire component search notifications + // -> May result in creation of new components under existing nodes + // - ChangeGameObjectStructureHierarchy: Check old and new parents, possibly fire component search notifications + // -> May result in creation of new components under existing nodes, or reparenting of components + // - ChangeGameObjectStructure: Check parents, possibly fire component search notifications + // -> Creates/deletes components + // - ChangeGameObjectOrComponentProperties: + // -> If component, fire single notification. If GameObject, this might be a component reordering, so fire + // the component search notifications as needed + // - CreateAssetObject: Ignored + // - DestroyAssetObject: Fire single object notifications + // - ChangeAssetObjectProperties: Fire single object notifications + // - UpdatePrefabInstances: Treated as ChangeGameObjectStructureHierarchy + // - ChangeChildrenOrder: Fire component search notifications + + // High level structure: + // We maintain a "shadow hierarchy" of GameObjects with their last known parent/child relationships. + // Since OCES doesn't give us the prior state, we need this to determine which parent objects need to be + // notified when objects move. Each shadow GameObject also tracks the last known set of components on the object. + // + // Listeners come in two flavors: object listeners (asset/component watches as well as parent watches), and + // component search listeners, which can be local or recursive. + + public static ObjectWatcher Instance { get; private set; } = new ObjectWatcher(); + internal ShadowHierarchy Hierarchy = new ShadowHierarchy(); + private readonly SynchronizationContext _syncContext = SynchronizationContext.Current; + private readonly int threadId = Thread.CurrentThread.ManagedThreadId; + + internal ObjectWatcher() + { + } + + [InitializeOnLoadMethod] + private static void Init() + { + SceneManager.sceneLoaded += (_, _) => Instance.Hierarchy.InvalidateAll(); + SceneManager.sceneUnloaded += _ => Instance.Hierarchy.InvalidateAll(); + SceneManager.activeSceneChanged += (_, _) => Instance.Hierarchy.InvalidateAll(); + } + + public ImmutableList MonitorSceneRoots(out IDisposable cancel, Action callback, T target) + where T : class + { + ImmutableList rootSet = GetRootSet(); + + // TODO scene load callbacks + + cancel = Hierarchy.RegisterRootSetListener((t, e) => + { + ImmutableList newRootSet = GetRootSet(); + if (!newRootSet.SequenceEqual(rootSet)) + { + InvokeCallback(callback, t); + + return true; + } + else + { + return false; + } + }, target); + + cancel = CancelWrapper(cancel); + + return rootSet; + } + + private ImmutableList GetRootSet() + { + ImmutableList.Builder roots = ImmutableList.CreateBuilder(); + + var sceneCount = SceneManager.sceneCount; + for (int i = 0; i < sceneCount; i++) + { + var scene = SceneManager.GetSceneAt(i); + if (!scene.IsValid() || !scene.isLoaded) continue; + + foreach (var go in scene.GetRootGameObjects()) + { + roots.Add(go); + } + } + + return roots.ToImmutable(); + } + + public void MonitorObjectPath(out IDisposable cancel, Transform t, Action callback, T target) + where T : class + { + cancel = Hierarchy.RegisterGameObjectListener(t.gameObject, (t, e) => + { + switch (e) + { + case HierarchyEvent.PathChange: + case HierarchyEvent.ForceInvalidate: + InvokeCallback(callback, t); + return true; + default: + return false; + } + }, target); + Hierarchy.EnablePathMonitoring(t.gameObject); + + cancel = CancelWrapper(cancel); + } + + public void MonitorObjectProps(out IDisposable cancel, UnityObject obj, Action callback, T target) + where T : class + { + cancel = default; + + if (obj is GameObject go) + { + cancel = Hierarchy.RegisterGameObjectListener(go, (t, e) => + { + switch (e) + { + case HierarchyEvent.ObjectDirty: + case HierarchyEvent.ForceInvalidate: + InvokeCallback(callback, t); + return true; + default: + return false; + } + }, target); + } + else + { + cancel = Hierarchy.RegisterObjectListener(obj, (t, e) => + { + switch (e) + { + case HierarchyEvent.ObjectDirty: + case HierarchyEvent.ForceInvalidate: + InvokeCallback(callback, t); + return true; + default: + return false; + } + }, target); + } + + cancel = CancelWrapper(cancel); + } + + private static void InvokeCallback(Action callback, object t) where T : class + { + try + { + callback((T)t); + } + catch (Exception e) + { + Debug.LogException(e); + } + } + + public C[] MonitorGetComponents(out IDisposable cancel, GameObject obj, Action callback, T target, + Func get, bool includeChildren) where T : class + { + cancel = default; + + C[] components = get(); + + Hierarchy.RegisterGameObjectListener(obj, (t, e) => + { + if (e == HierarchyEvent.ChildComponentsChanged && !includeChildren) return false; + + switch (e) + { + case HierarchyEvent.ChildComponentsChanged: + case HierarchyEvent.SelfComponentsChanged: + case HierarchyEvent.ForceInvalidate: + if (obj != null && components.SequenceEqual(get())) + { + return false; + } + else + { + InvokeCallback(callback, t); + return true; + } + default: + return false; + } + }, target); + + if (includeChildren) Hierarchy.EnableComponentMonitoring(obj); + + cancel = CancelWrapper(cancel); + + return components; + } + + public C MonitorGetComponent(out IDisposable cancel, GameObject obj, Action callback, T target, + Func get) where T : class + { + cancel = default; + + C component = get(); + + Hierarchy.RegisterGameObjectListener(obj, (t, e) => + { + switch (e) + { + case HierarchyEvent.SelfComponentsChanged: + case HierarchyEvent.ChildComponentsChanged: + case HierarchyEvent.ForceInvalidate: + if (obj != null && ReferenceEquals(component, get())) + { + return false; + } + else + { + InvokeCallback(callback, t); + return true; + } + default: + return false; + } + }, target); + + cancel = CancelWrapper(cancel); + + return component; + } + + class WrappedDisposable : IDisposable + { + private readonly int _targetThread; + private readonly SynchronizationContext _syncContext; + private IDisposable _orig; + + public WrappedDisposable(IDisposable orig, SynchronizationContext syncContext) + { + _orig = orig; + _targetThread = Thread.CurrentThread.ManagedThreadId; + _syncContext = syncContext; + } + + public void Dispose() + { + lock (this) + { + if (_orig == null) return; + + if (Thread.CurrentThread.ManagedThreadId == _targetThread) + { + _orig.Dispose(); + } + else + { + var orig = _orig; + _syncContext.Post(_ => orig.Dispose(), null); + } + + _orig = null; + } + } + } + + private IDisposable CancelWrapper(IDisposable orig) + { + return new WrappedDisposable(orig, _syncContext); + } + } +} \ No newline at end of file diff --git a/Editor/ChangeStream/ObjectWatcher.cs.meta b/Editor/ChangeStream/ObjectWatcher.cs.meta new file mode 100644 index 00000000..ea15eb7c --- /dev/null +++ b/Editor/ChangeStream/ObjectWatcher.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 156bbc8e03114b12aea9ebb5753450f4 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/ChangeStream/ShadowGameObject.cs b/Editor/ChangeStream/ShadowGameObject.cs new file mode 100644 index 00000000..88fb6aee --- /dev/null +++ b/Editor/ChangeStream/ShadowGameObject.cs @@ -0,0 +1,455 @@ +#region + +using System; +using System.Collections.Generic; +using System.Threading; +using UnityEditor; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +#endregion + +namespace nadena.dev.ndmf.rq.unity.editor +{ + #region + + using UnityObject = Object; + + #endregion + + internal enum HierarchyEvent + { + /// + /// Fired when an unspecified changed may have happened to this object. + /// + ObjectDirty, + + /// + /// Fired when the parentage of this object has changed. + /// + PathChange, + + /// + /// Fired when the set or order of components on this object may have changed + /// + SelfComponentsChanged, + + /// + /// Fired when the set or order of components on this object or any children may have changed + /// + ChildComponentsChanged, + + /// + /// Fired when an object is destroyed or otherwise we're not quite sure what's going on. + /// + ForceInvalidate, + } + + internal class ShadowHierarchy + { + internal SynchronizationContext _syncContext; + internal Dictionary _gameObjects = new(); + internal Dictionary _otherObjects = new(); + internal ListenerSet _rootSetListener = new(); + + int lastPruned = Int32.MinValue; + + internal IDisposable RegisterRootSetListener(ListenerSet.Invokee invokee, object target) + { + return _rootSetListener.Register(invokee, target); + } + + internal IDisposable RegisterGameObjectListener(GameObject targetObject, + ListenerSet.Invokee invokee, + object target) + { + if (targetObject == null) return new NullDisposable(); + + ShadowGameObject shadowObject = ActivateShadowObject(targetObject); + + return shadowObject._listeners.Register(invokee, target); + } + + internal IDisposable RegisterObjectListener(UnityObject targetComponent, + ListenerSet.Invokee invokee, + object target) + { + if (targetComponent == null) return new NullDisposable(); + + if (!_otherObjects.TryGetValue(targetComponent.GetInstanceID(), out var shadowComponent)) + { + shadowComponent = new ShadowObject(targetComponent); + _otherObjects[targetComponent.GetInstanceID()] = shadowComponent; + } + + return shadowComponent._listeners.Register(invokee, target); + } + + internal class NullDisposable : IDisposable + { + public void Dispose() + { + // no-op + } + } + + /// + /// Activates monitoring for all children of the specified GameObject. This is needed to ensure child component + /// change notifications are propagated correctly. + /// + /// + internal void EnableComponentMonitoring(GameObject root) + { + var obj = ActivateShadowObject(root); + + EnableComponentMonitoring(obj); + } + + private void EnableComponentMonitoring(ShadowGameObject obj) + { + if (obj.ComponentMonitoring) return; + + foreach (Transform child in obj.GameObject.transform) + { + EnableComponentMonitoring(child.gameObject); + } + + // Enable the parent last to avoid nuisance notifications + obj.ComponentMonitoring = true; + } + + internal void EnablePathMonitoring(GameObject root) + { + var obj = ActivateShadowObject(root); + + while (obj != null) + { + obj.PathMonitoring = true; + obj = obj.Parent; + } + } + + private ShadowGameObject ActivateShadowObject(GameObject targetObject) + { + // An object is activated when it, or a parent, has a listener attached. + // An object is deactivated ("inert") when we traverse it and find no listeners in any of its children. + // Inert objects are skipped for path update notifications; however, we can't just delete them, because + // we may need to know about them for future structure change notifications at their parents. + int instanceId = targetObject.GetInstanceID(); + if (!_gameObjects.TryGetValue(instanceId, out var shadow)) + { + shadow = new ShadowGameObject(targetObject); + _gameObjects[instanceId] = shadow; + + shadow.Scene = targetObject.scene; + var parent = targetObject.transform.parent?.gameObject; + if (parent == null) + { + shadow.SetParent(null, false); + _rootSetListener.Fire(HierarchyEvent.ForceInvalidate); + } + else + { + // Don't fire notifications on initial creation + shadow.SetParent(ActivateShadowObject(parent), false); + } + } + + if (shadow.Parent?.ComponentMonitoring == true && !shadow.ComponentMonitoring) + { + EnableComponentMonitoring(shadow); + FireParentComponentChangeNotifications(shadow.Parent); + } + + return shadow; + } + + /// + /// Fires a notification that properties on a specific object (GameObject or otherwise) has changed. + /// + /// + internal void FireObjectChangeNotification(int instanceId) + { + if (_gameObjects.TryGetValue(instanceId, out var shadow)) + { + shadow._listeners.Fire(HierarchyEvent.ObjectDirty); + if (shadow.IsActive != shadow.GameObject.activeSelf) + { + shadow.IsActive = shadow.GameObject.activeSelf; + FirePathChangeNotifications(shadow); + } + } + + var component = EditorUtility.InstanceIDToObject(instanceId) as Component; + if (component != null) + { + // This event may have been a component reordering, so trigger a synthetic structure change event. + // TODO: Cache component positions? + var parentId = component.gameObject.GetInstanceID(); + FireStructureChangeEvent(parentId); + } + + if (_otherObjects.TryGetValue(instanceId, out var shadowComponent)) + { + shadowComponent._listeners.Fire(HierarchyEvent.ObjectDirty); + } + } + + /// + /// Fires a notification that the specified GameObject has a new parent. + /// + /// + internal void FireReparentNotification(int instanceId) + { + // Always activate on reparent. This is because we might be reparenting _into_ an active hierarchy. + var obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; + ShadowGameObject shadow; + if (obj != null) + { + shadow = ActivateShadowObject(obj); + } + else + { + if (_gameObjects.TryGetValue(instanceId, out var _)) + { + FireDestroyNotification(instanceId); + } + + return; + } + + FireParentComponentChangeNotifications(shadow.Parent); + if (shadow.PathMonitoring) FirePathChangeNotifications(shadow); + + // Ensure the new parent is marked as dirty, in case this is a new object and we suppressed the dirty + // notifications. + if (shadow.Parent != null) shadow.Parent._listeners.Fire(HierarchyEvent.ObjectDirty); + + // Update parentage and refire + + var newParent = shadow.GameObject.transform.parent?.gameObject; + if (newParent == null) + { + shadow.Parent = null; + _rootSetListener.Fire(HierarchyEvent.ForceInvalidate); + } + else if (newParent != shadow.Parent?.GameObject) + { + if (shadow.Parent == null) _rootSetListener.Fire(HierarchyEvent.ForceInvalidate); + + shadow.Parent = ActivateShadowObject(newParent); + FireParentComponentChangeNotifications(shadow.Parent); + + var ptr = shadow.Parent; + while (ptr != null && !ptr.PathMonitoring) + { + ptr.PathMonitoring = true; + ptr = ptr.Parent; + } + } + + // This needs to run even if the parent did not change, just in case we did a just-in-time creation of this + // shadow object. + if (shadow.Parent?.ComponentMonitoring == true) EnableComponentMonitoring(shadow); + } + + private void FirePathChangeNotifications(ShadowGameObject shadow) + { + if (!shadow.PathMonitoring) return; + shadow._listeners.Fire(HierarchyEvent.PathChange); + foreach (var child in shadow.Children) + { + FirePathChangeNotifications(child); + } + } + + private void FireParentComponentChangeNotifications(ShadowGameObject obj) + { + while (obj != null) + { + obj._listeners.Fire(HierarchyEvent.ChildComponentsChanged); + obj = obj.Parent; + } + } + + internal void FireDestroyNotification(int instanceId) + { + if (_gameObjects.TryGetValue(instanceId, out var shadow)) + { + FireParentComponentChangeNotifications(shadow.Parent); + ForceInvalidateHierarchy(shadow); + } + } + + void ForceInvalidateHierarchy(ShadowGameObject obj) + { + obj._listeners.Fire(HierarchyEvent.ForceInvalidate); + _gameObjects.Remove(obj.InstanceID); + + foreach (var child in obj.Children) + { + ForceInvalidateHierarchy(child); + } + } + + internal void FireReorderNotification(int parentInstanceId) + { + if (!_gameObjects.TryGetValue(parentInstanceId, out var shadow)) + { + return; + } + + FireParentComponentChangeNotifications(shadow); + } + + internal void FireStructureChangeEvent(int instanceId) + { + if (!_gameObjects.TryGetValue(instanceId, out var shadow)) + { + return; + } + + shadow._listeners.Fire(HierarchyEvent.SelfComponentsChanged); + FireParentComponentChangeNotifications(shadow.Parent); + } + + internal void InvalidateAll() + { + var oldDict = _gameObjects; + _gameObjects = new Dictionary(); + + foreach (var shadow in oldDict.Values) + { + shadow._listeners.Fire(HierarchyEvent.ForceInvalidate); + } + + var oldComponents = _otherObjects; + _otherObjects = new Dictionary(); + + foreach (var shadow in oldComponents.Values) + { + shadow._listeners.Fire(HierarchyEvent.ForceInvalidate); + } + + _rootSetListener.Fire(HierarchyEvent.ForceInvalidate); + } + + /// + /// Assume that everything has changed for the specified object and its children. Fire off all relevant + /// notifications and rebuild state. + /// + /// + /// + public void InvalidateTree(int instanceId) + { + if (_gameObjects.TryGetValue(instanceId, out var shadow)) + { + shadow._listeners.Fire(HierarchyEvent.ForceInvalidate); + FireParentComponentChangeNotifications(shadow.Parent); + _gameObjects.Remove(instanceId); + + var parentGameObject = shadow.Parent?.GameObject; + + if (parentGameObject != null) + { + // Repair parent's child mappings + foreach (Transform child in parentGameObject.transform) + { + ActivateShadowObject(child.gameObject); + } + } + + // Finally recreate the target object, just in case it took up some objects from somewhere else + var gameObject = EditorUtility.InstanceIDToObject(instanceId) as GameObject; + if (gameObject != null) + { + ActivateShadowObject(gameObject); + } + } + } + + public void FireGameObjectCreate(int instanceId) + { + var obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; + if (obj == null) return; + + var shadow = ActivateShadowObject(obj); + + // Ensure the new parent is marked as dirty + if (shadow.Parent != null) shadow.Parent._listeners.Fire(HierarchyEvent.ObjectDirty); + } + } + + internal class ShadowObject + { + internal int InstanceID { get; private set; } + internal UnityObject Object { get; private set; } + + internal ListenerSet _listeners = new ListenerSet(); + + internal ShadowObject(UnityObject component) + { + InstanceID = component.GetInstanceID(); + Object = component; + } + } + + /// + /// Represents a single GameObject in a loaded scene. This shadow copy will be retained, once an interest is + /// registered, until the GameObject is destroyed or scene unloaded. + /// + internal class ShadowGameObject + { + internal int InstanceID { get; private set; } + internal GameObject GameObject { get; private set; } + internal Scene Scene { get; set; } + private readonly Dictionary _children = new Dictionary(); + + public IEnumerable Children => _children.Values; + + + private ShadowGameObject _parent; + internal bool PathMonitoring { get; set; } = false; + internal bool ComponentMonitoring { get; set; } = false; + internal bool IsActive { get; set; } + + internal ShadowGameObject Parent + { + get => _parent; + set { SetParent(value, true); } + } + + + public void SetParent(ShadowGameObject parent, bool fireNotifications = true) + { + if (parent == _parent) return; + + if (_parent != null) + { + _parent._children.Remove(InstanceID); + // Fire off a property change notification for the parent itself + // TODO: tests + if (fireNotifications) _parent._listeners.Fire(HierarchyEvent.ObjectDirty); + } + + _parent = parent; + + if (_parent != null) + { + _parent._children[InstanceID] = this; + if (fireNotifications) _parent._listeners.Fire(HierarchyEvent.ObjectDirty); + } + } + + internal ListenerSet _listeners = new ListenerSet(); + + internal ShadowGameObject(GameObject gameObject) + { + InstanceID = gameObject.GetInstanceID(); + GameObject = gameObject; + Scene = gameObject.scene; + IsActive = gameObject.activeSelf; + } + } +} \ No newline at end of file diff --git a/Editor/ChangeStream/ShadowGameObject.cs.meta b/Editor/ChangeStream/ShadowGameObject.cs.meta new file mode 100644 index 00000000..0088a98a --- /dev/null +++ b/Editor/ChangeStream/ShadowGameObject.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 08dfb4befa154fd585bd4a3be94b3200 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/GlobalInit.cs b/Editor/GlobalInit.cs index 4061db98..f90c94e7 100644 --- a/Editor/GlobalInit.cs +++ b/Editor/GlobalInit.cs @@ -1,5 +1,6 @@ #region +using nadena.dev.ndmf.preview; using nadena.dev.ndmf.runtime; using UnityEditor; @@ -13,6 +14,12 @@ internal static class GlobalInit static GlobalInit() { RuntimeUtil.DelayCall = call => { EditorApplication.delayCall += () => call(); }; + + EditorApplication.delayCall += () => + { + var resolver = new PluginResolver(); + PreviewSession.Current = resolver.PreviewSession; + }; } } } \ No newline at end of file diff --git a/Editor/PreviewSystem.meta b/Editor/PreviewSystem.meta new file mode 100644 index 00000000..8f4ee21f --- /dev/null +++ b/Editor/PreviewSystem.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 76de2fe061d942b5b53fa9dee427dc20 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Harmony.meta b/Editor/PreviewSystem/Harmony.meta new file mode 100644 index 00000000..8ecbdcf9 --- /dev/null +++ b/Editor/PreviewSystem/Harmony.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9f996c23d7ef47e69921d72865c76b60 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Harmony/HandleUtilityPatches.cs b/Editor/PreviewSystem/Harmony/HandleUtilityPatches.cs new file mode 100644 index 00000000..c1d8bd37 --- /dev/null +++ b/Editor/PreviewSystem/Harmony/HandleUtilityPatches.cs @@ -0,0 +1,89 @@ +#region + +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using JetBrains.Annotations; +using UnityEngine; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal static class HandleUtilityPatches + { + internal static void Patch_FilterInstanceIDs(Harmony h) + { + var t_HandleUtility = AccessTools.TypeByName("UnityEditor.HandleUtility"); + var m_orig = AccessTools.Method(t_HandleUtility, "FilterInstanceIDs"); + + var m_prefix = AccessTools.Method(typeof(HandleUtilityPatches), "Prefix_FilterInstanceIDs"); + var m_postfix = AccessTools.Method(typeof(HandleUtilityPatches), "Postfix_FilterInstanceIDs"); + + h.Patch(original: m_orig, prefix: new HarmonyMethod(m_prefix), postfix: new HarmonyMethod(m_postfix)); + } + + [UsedImplicitly] + private static bool Prefix_FilterInstanceIDs( + ref IEnumerable gameObjects, + out int[] parentInstanceIDs, + out int[] childInstanceIDs + ) + { + gameObjects = RemapObjects(gameObjects); + parentInstanceIDs = childInstanceIDs = null; + return true; + } + + [UsedImplicitly] + private static void Postfix_FilterInstanceIDs( + ref IEnumerable gameObjects, + ref int[] parentInstanceIDs, + ref int[] childInstanceIDs + ) + { + var sess = PreviewSession.Current; + if (sess == null) return; + + HashSet newChildInstanceIDs = null; + + foreach (var parent in gameObjects) + { + foreach (var renderer in parent.GetComponentsInChildren()) + { + if (sess.OriginalToProxyRenderer.TryGetValue(renderer, out var proxy) && proxy != null) + { + if (newChildInstanceIDs == null) newChildInstanceIDs = new HashSet(childInstanceIDs); + newChildInstanceIDs.Add(proxy.GetInstanceID()); + } + } + } + + if (newChildInstanceIDs != null) + { + childInstanceIDs = newChildInstanceIDs.ToArray(); + } + } + + private static IEnumerable RemapObjects(IEnumerable objs) + { + var sess = PreviewSession.Current; + if (sess == null) return objs; + + return objs.Select( + obj => + { + if (obj == null) return obj; + if (sess.OriginalToProxyObject.TryGetValue(obj, out var proxy) && proxy != null) + { + return proxy.gameObject; + } + else + { + return obj; + } + } + ).ToArray(); + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Harmony/HandleUtilityPatches.cs.meta b/Editor/PreviewSystem/Harmony/HandleUtilityPatches.cs.meta new file mode 100644 index 00000000..e86b9c36 --- /dev/null +++ b/Editor/PreviewSystem/Harmony/HandleUtilityPatches.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: eae12856a2de4a82b02fd3109970791f +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Harmony/HierarchyViewPatches.cs b/Editor/PreviewSystem/Harmony/HierarchyViewPatches.cs new file mode 100644 index 00000000..0053d792 --- /dev/null +++ b/Editor/PreviewSystem/Harmony/HierarchyViewPatches.cs @@ -0,0 +1,181 @@ +#region + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using HarmonyLib; +using JetBrains.Annotations; +using UnityEditor.IMGUI.Controls; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal static class HierarchyViewPatches + { + private static readonly Type t_HierarchyProperty = AccessTools.TypeByName("UnityEditor.HierarchyProperty"); + private static readonly PropertyInfo p_pptrValue = AccessTools.Property(t_HierarchyProperty, "pptrValue"); + + private static FieldInfo f_m_Rows; // List + private static FieldInfo f_m_RowCount; // int + private static PropertyInfo p_objectPPTR; + + internal static void Patch(Harmony h) + { +#if MODULAR_AVATAR_DEBUG_HIDDEN + return; +#endif + var t_GameObjectTreeViewDataSource = AccessTools.TypeByName("UnityEditor.GameObjectTreeViewDataSource"); + var t_GameObjectTreeViewItem = AccessTools.TypeByName("UnityEditor.GameObjectTreeViewItem"); + + f_m_Rows = t_GameObjectTreeViewDataSource.GetField("m_Rows", + BindingFlags.NonPublic | BindingFlags.Instance); + f_m_RowCount = + t_GameObjectTreeViewDataSource.GetField("m_RowCount", BindingFlags.NonPublic | BindingFlags.Instance); + p_objectPPTR = t_GameObjectTreeViewItem.GetProperty("objectPPTR"); + + var m_orig = AccessTools.Method(t_GameObjectTreeViewDataSource, "InitTreeViewItem", + new[] + { + t_GameObjectTreeViewItem, + typeof(int), + typeof(Scene), + typeof(bool), + typeof(int), + typeof(Object), + typeof(bool), + typeof(int) + }); + var m_patch = AccessTools.Method(typeof(HierarchyViewPatches), nameof(Prefix_InitTreeViewItem)); + + h.Patch(original: m_orig, prefix: new HarmonyMethod(m_patch)); + + var m_InitRows = AccessTools.Method(t_GameObjectTreeViewDataSource, "InitializeRows"); + var m_transpiler = AccessTools.Method(typeof(HierarchyViewPatches), "Transpile_InitializeRows"); + + h.Patch(original: m_InitRows, + transpiler: new HarmonyMethod(m_transpiler), + postfix: new HarmonyMethod(AccessTools.Method(typeof(HierarchyViewPatches), "Postfix_InitializeRows")), + prefix: new HarmonyMethod(AccessTools.Method(typeof(HierarchyViewPatches), "Prefix_InitializeRows")) + ); + } + + private static int skipped = 0; + + private static void Prefix_InitializeRows() + { + skipped = 0; + } + + [UsedImplicitly] + private static void Postfix_InitializeRows(object __instance) + { + var rows = (IList)f_m_Rows.GetValue(__instance); + + var rowCount = (int)f_m_RowCount.GetValue(__instance); + + f_m_RowCount.SetValue(__instance, rowCount - skipped); + + for (int i = 0; i < skipped; i++) + { + rows.RemoveAt(rows.Count - 1); + } + } + + [UsedImplicitly] + private static IEnumerable Transpile_InitializeRows(IEnumerable instructions, + ILGenerator generator) + { + foreach (var c in Transpile_InitializeRows0(instructions, generator)) + { + //Debug.Log(c); + yield return c; + } + } + + [UsedImplicitly] + private static IEnumerable Transpile_InitializeRows0(IEnumerable instructions, + ILGenerator generator) + { + var m_shouldLoop = AccessTools.Method(typeof(HierarchyViewPatches), "ShouldLoop"); + + var m_Next = AccessTools.Method(t_HierarchyProperty, "Next", new[] { typeof(int[]) }); + + foreach (var c in instructions) + { + if (c.Is(OpCodes.Callvirt, m_Next)) + { + var loopLabel = generator.DefineLabel(); + var stash_arg = generator.DeclareLocal(typeof(int[])); + var stash_obj = generator.DeclareLocal(t_HierarchyProperty); + + yield return new CodeInstruction(OpCodes.Stloc, stash_arg); + yield return new CodeInstruction(OpCodes.Stloc, stash_obj); + + var tmp = new CodeInstruction(OpCodes.Ldloc, stash_obj); + tmp.labels.Add(loopLabel); + yield return tmp; + + yield return new CodeInstruction(OpCodes.Ldloc, stash_arg); + yield return new CodeInstruction(OpCodes.Call, m_Next); + + // Check if this item should be ignored. + yield return new CodeInstruction(OpCodes.Ldloc, stash_obj); + yield return new CodeInstruction(OpCodes.Call, m_shouldLoop); + yield return new CodeInstruction(OpCodes.Brtrue_S, loopLabel); + } + else + { + yield return c; + } + } + } + + [UsedImplicitly] + private static bool ShouldLoop(object hierarchyProperty) + { + var sess = PreviewSession.Current; + if (sess == null) return false; + + if (hierarchyProperty == null) return false; + + var pptrValue = p_pptrValue.GetValue(hierarchyProperty); + if (pptrValue == null) return false; + + var skip = sess.ProxyToOriginalObject.ContainsKey((GameObject)pptrValue); + if (skip) skipped++; + + return skip; + } + + private static bool Prefix_InitTreeViewItem( + object __instance, + ref object item, + int itemID, + Scene scene, + bool isSceneHeader, + int colorCode, + Object pptrObject, + ref bool hasChildren, + int depth + ) + { + var sess = PreviewSession.Current; + if (sess == null) return true; + + if (pptrObject == null || isSceneHeader) return true; + + if (hasChildren && sess.ProxyToOriginalObject.ContainsKey((GameObject)pptrObject)) + { + // See if there are any other children... + hasChildren = ((GameObject)pptrObject).transform.childCount > 1; + } + + return true; + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Harmony/HierarchyViewPatches.cs.meta b/Editor/PreviewSystem/Harmony/HierarchyViewPatches.cs.meta new file mode 100644 index 00000000..c87d2e6e --- /dev/null +++ b/Editor/PreviewSystem/Harmony/HierarchyViewPatches.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4b540a556667468dbfae0893180dbdb2 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Harmony/Patcher.cs b/Editor/PreviewSystem/Harmony/Patcher.cs new file mode 100644 index 00000000..a6e9cc33 --- /dev/null +++ b/Editor/PreviewSystem/Harmony/Patcher.cs @@ -0,0 +1,41 @@ +#region + +using System; +using HarmonyLib; +using UnityEditor; +using UnityEngine; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal sealed class PatchLoader + { + private static readonly Action[] patches = + { + HandleUtilityPatches.Patch_FilterInstanceIDs, + PickingObjectPatch.Patch, + HierarchyViewPatches.Patch + }; + + [InitializeOnLoadMethod] + static void ApplyPatches() + { + var harmony = new Harmony("nadena.dev.ndmf.core.preview"); + + foreach (var patch in patches) + { + try + { + patch(harmony); + } + catch (Exception e) + { + Debug.LogException(e); + } + } + + AssemblyReloadEvents.beforeAssemblyReload += () => { harmony.UnpatchAll(); }; + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Harmony/Patcher.cs.meta b/Editor/PreviewSystem/Harmony/Patcher.cs.meta new file mode 100644 index 00000000..2367155c --- /dev/null +++ b/Editor/PreviewSystem/Harmony/Patcher.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7ad14393462d40afa5025de0dedf8f3c +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Harmony/PickingObjectPatch.cs b/Editor/PreviewSystem/Harmony/PickingObjectPatch.cs new file mode 100644 index 00000000..0262ab9c --- /dev/null +++ b/Editor/PreviewSystem/Harmony/PickingObjectPatch.cs @@ -0,0 +1,82 @@ +#region + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using UnityEngine; +using Object = UnityEngine.Object; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal static class PickingObjectPatch + { + private static Type t_PickingObject = AccessTools.TypeByName("UnityEditor.PickingObject"); + + private static Type l_PickingObject = + typeof(List<>).MakeGenericType(new[] { t_PickingObject }); + + private static ConstructorInfo ctor_l = AccessTools.Constructor(l_PickingObject); + + private static ConstructorInfo ctor_PickingObject = + AccessTools.Constructor(t_PickingObject, new[] { typeof(Object), typeof(int) }); + + private static PropertyInfo p_materialIndex = AccessTools.Property(t_PickingObject, "materialIndex"); + + private static MethodInfo m_TryGetGameObject = AccessTools.Method(t_PickingObject, "TryGetGameObject"); + + internal static void Patch(Harmony h) + { + var t_PickingObject = AccessTools.TypeByName("UnityEditor.PickingObject"); + var ctor_PickingObject = AccessTools.Constructor(t_PickingObject, new[] { typeof(Object), typeof(int) }); + + var t_SceneViewPicking = AccessTools.TypeByName("UnityEditor.SceneViewPicking"); + var m_GetAllOverlapping = AccessTools.Method(t_SceneViewPicking, "GetAllOverlapping"); + + var m_postfix = AccessTools.Method(typeof(PickingObjectPatch), nameof(Postfix_GetAllOverlapping)); + + h.Patch(original: m_GetAllOverlapping, postfix: new HarmonyMethod(m_postfix)); + } + + private static void Postfix_GetAllOverlapping(ref object __result) + { + var erased = (IEnumerable)__result; + + var sess = PreviewSession.Current; + if (sess == null) return; + + var list = (IList)ctor_l.Invoke(new object[0]); + + foreach (var obj in erased) + { + if (obj == null) + { + list.Add(obj); + continue; + } + + var args = new object[] { null }; + if ((bool)m_TryGetGameObject.Invoke(obj, args)) + { + var go = args[0] as GameObject; + if (go != null && sess.ProxyToOriginalObject.TryGetValue(go, out var original)) + { + list.Add(ctor_PickingObject.Invoke(new[] + { + original, + p_materialIndex.GetValue(obj) + })); + continue; + } + } + + list.Add(obj); + } + + __result = list; + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Harmony/PickingObjectPatch.cs.meta b/Editor/PreviewSystem/Harmony/PickingObjectPatch.cs.meta new file mode 100644 index 00000000..5bb69c97 --- /dev/null +++ b/Editor/PreviewSystem/Harmony/PickingObjectPatch.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1fcf08c6e9b946ff8d9162f35edfc2c2 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/IRenderFilter.cs b/Editor/PreviewSystem/IRenderFilter.cs new file mode 100644 index 00000000..742f9de4 --- /dev/null +++ b/Editor/PreviewSystem/IRenderFilter.cs @@ -0,0 +1,156 @@ +#region + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using nadena.dev.ndmf.rq; +using UnityEngine; +using Object = UnityEngine.Object; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + /// + /// Represents the current state of a mesh. IRenderFilters mutate this state in order to perform heavyweight portions + /// of a preview rendering operation. + /// + /// TODO: This API is likely to change radically in future alpha releases. + /// + public sealed class MeshState + { + internal long NodeId { get; } + + /// + /// The original renderer that this MeshState was born from + /// + public Renderer Original { get; } + + private bool _meshIsOwned; + private Mesh _mesh; + + /// + /// The current mesh associated with this renderer. Important: When setting this value, you must set to a _new_ + /// mesh. This new mesh will be destroyed when the preview state is recomputed. + /// + public Mesh Mesh + { + get => _mesh; + set + { + _meshIsOwned = true; + _mesh = value; + if (_mesh != null) _mesh.name = "Mesh #" + NodeId; + } + } + + private bool _materialsAreOwned; + private ImmutableList _materials; + + /// + /// The materials associated with this mesh. Important: When setting this value, you must set to a list of entirely + /// _new_ materials. These new materials will be destroyed when the preview state is recomputed. + /// + public ImmutableList Materials + { + get => _materials; + set + { + _materials = value; + _materialsAreOwned = true; + } + } + + /// + /// An event which will be invoked when the mesh state is discarded. This can be used to destroy any resources + /// you've created other than Meshes and Materials - e.g. textures. + /// + public event Action OnDispose; + + private bool _disposed = false; + + internal MeshState(Renderer renderer) + { + Original = renderer; + + if (renderer is SkinnedMeshRenderer smr) + { + Mesh = Object.Instantiate(smr.sharedMesh); + } + else if (renderer is MeshRenderer mr) + { + Mesh = Object.Instantiate(mr.GetComponent().sharedMesh); + } + + Materials = renderer.sharedMaterials.Select(m => new Material(m)).ToImmutableList(); + } + + private MeshState(MeshState state, long nodeId) + { + Original = state.Original; + _mesh = state._mesh; + _materials = state._materials; + NodeId = nodeId; + } + + // Not IDisposable as we don't want to expose that as a public API + internal void Dispose() + { + if (_disposed) return; + + if (_meshIsOwned) Object.DestroyImmediate(Mesh); + if (_materialsAreOwned) + { + foreach (var material in Materials) + { + Object.DestroyImmediate(material); + } + } + + OnDispose?.Invoke(); + } + + internal MeshState Clone(long nodeId) + { + return new MeshState(this, nodeId); + } + } + + /// + /// An interface implemented by components which need to modify the appearance of a renderer for preview purposes. + /// + public interface IRenderFilter + { + /// + /// A list of lists of renderers this filter operates on. The outer list is a list of renderer groups; each + /// group of renderers will be passed to this filter as one unit, allowing for cross-renderer operation such + /// as texture atlasing. + /// + public ReactiveValue>> TargetGroups { get; } + + /// + /// Performs any heavyweight operations required to prepare the renderers for preview. This method is called + /// once when the preview pipeline is set up. You can use the attached ComputeContext to arrange for the + /// preview pipeline to be recomputed when something changes with the initial state of the renderer. + /// + /// + /// + /// + public Task MutateMeshData(IList state, ComputeContext context) + { + return Task.CompletedTask; + } + + /// + /// Called on each frame to perform lighter-weight operations on the renderers, such as manipulating blend shapes + /// or the bones array. + /// + /// + /// + public void OnFrame(Renderer original, Renderer proxy) + { + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/IRenderFilter.cs.meta b/Editor/PreviewSystem/IRenderFilter.cs.meta new file mode 100644 index 00000000..25826298 --- /dev/null +++ b/Editor/PreviewSystem/IRenderFilter.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7402f7b9799943388df99b19bc982f7b +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/PreviewSession.cs b/Editor/PreviewSystem/PreviewSession.cs new file mode 100644 index 00000000..a31009ee --- /dev/null +++ b/Editor/PreviewSystem/PreviewSession.cs @@ -0,0 +1,145 @@ +#region + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using nadena.dev.ndmf.rq; +using UnityEngine; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + /// + /// TODO: Document + /// + /// (For now, this isn't very useful; use `DeclaringPass.PreviewingWith` instead) + /// + public class PreviewSession : IDisposable + { + #region Static State + + /// + /// The PreviewSession used for any cameras not overriden using `OverrideCamera`. + /// + public static PreviewSession Current { get; set; } = null; + + + /// + /// Applies this PreviewSession to the `target` camera. + /// + /// + public void OverrideCamera(Camera target) + { + throw new NotImplementedException(); + } + + /// + /// Removes all camera overrides from the `target` camera. + /// + /// + public static void ClearCameraOverride(Camera target) + { + throw new NotImplementedException(); + } + + #endregion + + internal IEnumerable<(Renderer, Renderer)> GetReplacements() + { + return _session?.OnPreCull() ?? Enumerable.Empty<(Renderer, Renderer)>(); + } + + internal ImmutableDictionary OriginalToProxyRenderer => + _session?.OriginalToProxyRenderer ?? ImmutableDictionary.Empty; + + internal ImmutableDictionary OriginalToProxyObject => + _session?.OriginalToProxyObject ?? ImmutableDictionary.Empty; + + internal ImmutableDictionary ProxyToOriginalObject => + _session?.ProxyToOriginalObject ?? ImmutableDictionary.Empty; + + private readonly Sequencer _sequence = new Sequencer(); + + private Dictionary _filters = new(); + + private ProxySession _session; + + private ReactiveField> _resolved; + + public PreviewSession() + { + _resolved = new ReactiveField>( + ImmutableList.Empty + ); + + _session = new ProxySession(_resolved.AsReactiveValue()); + } + + /// + /// Sets the order in which mesh mutations are executed. Any sequence points not listed in this sequence will + /// be executed after these registered points, in `AddMutator` invocation order. + /// + /// + public void SetSequence(IEnumerable sequencePoints) + { + _sequence.SetSequence(sequencePoints); + + RebuildSequence(); + } + + public IDisposable AddMutator(SequencePoint sequencePoint, IRenderFilter filter) + { + _sequence.AddPoint(sequencePoint); + + _filters.Add(sequencePoint, filter); + + RebuildSequence(); + + return new RemovalDisposable(this, sequencePoint); + } + + private class RemovalDisposable : IDisposable + { + private PreviewSession _session; + private SequencePoint _point; + + public RemovalDisposable(PreviewSession session, SequencePoint point) + { + _session = session; + _point = point; + } + + public void Dispose() + { + _session._filters.Remove(_point); + _session.RebuildSequence(); + } + } + + void RebuildSequence() + { + var sequence = _sequence.Sequence; + var filters = sequence.Select(p => _filters.GetValueOrDefault(p)).Where(f => f != null).ToImmutableList(); + + _resolved.Value = filters; + } + + /// + /// Returns a new PreviewSession which inherits all mutators from the parent session. Any mutators added to this + /// new session run after the parent session's mutators. + /// + /// + /// + public PreviewSession Fork() + { + throw new NotImplementedException(); + } + + public void Dispose() + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/PreviewSession.cs.meta b/Editor/PreviewSystem/PreviewSession.cs.meta new file mode 100644 index 00000000..d56cea44 --- /dev/null +++ b/Editor/PreviewSystem/PreviewSession.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 49d69a234a9b41188b774b2804934e0c +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/ProxyManager.cs b/Editor/PreviewSystem/ProxyManager.cs new file mode 100644 index 00000000..a5056272 --- /dev/null +++ b/Editor/PreviewSystem/ProxyManager.cs @@ -0,0 +1,69 @@ +#region + +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + /// + /// Tracks the proxy meshes created by the preview system. + /// + internal static class ProxyManager + { + [InitializeOnLoadMethod] + private static void Initialize() + { + Camera.onPreCull += OnPreCull; + Camera.onPostRender += OnPostRender; + EditorSceneManager.sceneSaving += (_, _) => ResetStates(); + AssemblyReloadEvents.beforeAssemblyReload += ResetStates; + } + + private static List<(Renderer, bool)> _resetActions = new(); + + private static void OnPostRender(Camera cam) + { + ResetStates(); + } + + private static void OnPreCull(Camera cam) + { + ResetStates(); + + if (EditorApplication.isPlayingOrWillChangePlaymode) return; + + var sess = PreviewSession.Current; + if (sess == null) return; + + foreach (var (original, replacement) in sess.GetReplacements()) + { + if (original == null || replacement == null || !original.enabled || + !original.gameObject.activeInHierarchy) + { + if (replacement != null) replacement.forceRenderingOff = true; + continue; + } + + _resetActions.Add((original, false)); + _resetActions.Add((replacement, true)); + + replacement.forceRenderingOff = false; + original.forceRenderingOff = true; + } + } + + private static void ResetStates() + { + foreach (var (renderer, state) in _resetActions) + { + if (renderer != null) renderer.forceRenderingOff = state; + } + + _resetActions.Clear(); + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/ProxyManager.cs.meta b/Editor/PreviewSystem/ProxyManager.cs.meta new file mode 100644 index 00000000..a8d707d6 --- /dev/null +++ b/Editor/PreviewSystem/ProxyManager.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b068eb5455ea44c5ad2c8f647d9026dc +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering.meta b/Editor/PreviewSystem/Rendering.meta new file mode 100644 index 00000000..2e279f38 --- /dev/null +++ b/Editor/PreviewSystem/Rendering.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6a659c6fb8074b9db08a2d4da342f2cc +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/NodeGraph.cs b/Editor/PreviewSystem/Rendering/NodeGraph.cs new file mode 100644 index 00000000..186a95eb --- /dev/null +++ b/Editor/PreviewSystem/Rendering/NodeGraph.cs @@ -0,0 +1,40 @@ +#region + +using System; +using System.Collections.Generic; +using System.Linq; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal class NodeGraph + { + private Dictionary _nodes = new(); + + public ProxyNode GetOrCreate(ProxyNodeKey key, Func OnMissing) + { + if (!_nodes.TryGetValue(key, out var node) || node.Invalidated || node.PrepareTask.IsFaulted) + { + node = OnMissing(); + _nodes[key] = node; + } + + return node; + } + + public void Retain(ISet nodesToRetain) + { + foreach (var key in _nodes.Keys.ToList()) + { + if (!nodesToRetain.Contains(key)) + { + var node = _nodes[key]; + _nodes.Remove(key); + + node.Dispose(); + } + } + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/NodeGraph.cs.meta b/Editor/PreviewSystem/Rendering/NodeGraph.cs.meta new file mode 100644 index 00000000..84160cc2 --- /dev/null +++ b/Editor/PreviewSystem/Rendering/NodeGraph.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 61b7a0313a584f89a2c94453d596f264 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/ProxyNode.cs b/Editor/PreviewSystem/Rendering/ProxyNode.cs new file mode 100644 index 00000000..7694dc8e --- /dev/null +++ b/Editor/PreviewSystem/Rendering/ProxyNode.cs @@ -0,0 +1,171 @@ +#region + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using nadena.dev.ndmf.rq; +using UnityEngine; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal struct ProxyNodeKey + { + public IRenderFilter Filter; + public ImmutableList Sources; // Sorted + + public ProxyNodeKey(Renderer r) + { + Filter = null; + Sources = ImmutableList.Empty.Add(r.GetInstanceID()); + } + + public ProxyNodeKey(IRenderFilter filter, IEnumerable sources) + { + Filter = filter; + Sources = sources.OrderBy(t => t).ToImmutableList(); + } + + public bool Equals(ProxyNodeKey other) + { + return ReferenceEquals(Filter, other.Filter) && Sources.SequenceEqual(other.Sources); + } + + public override bool Equals(object obj) + { + return obj is ProxyNodeKey other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = (Filter != null ? Filter.GetHashCode() : 0); + + foreach (var source in Sources) + { + hashCode = hashCode * 397 ^ source.GetHashCode(); + } + + return hashCode; + } + } + } + + internal class ProxyNode : IDisposable + { + private static long IdSequence = Int32.MaxValue; + + public long Id { get; } + public ProxyNodeKey Key { get; private set; } + public IRenderFilter Filter => Key.Filter; + + public ImmutableDictionary SourceNodes { get; } + public Task> PrepareTask { get; } + + private readonly TaskCompletionSource _invalidater = new(); + public Task InvalidatedTask => _invalidater.Task; + + public bool Invalidated => InvalidatedTask.IsCompleted; + public bool Disposed { get; private set; } + + public ProxyNode(Renderer renderer) + { + Id = renderer.GetInstanceID(); + SourceNodes = ImmutableDictionary.Empty; + Key = new ProxyNodeKey(null, new long[] { renderer.GetInstanceID() }); + PrepareTask = Task.FromResult(ImmutableDictionary.Empty.Add( + renderer, + new MeshState(renderer) + )); + } + + public ProxyNode( + IRenderFilter filter, + IImmutableList renderGroup, + ImmutableDictionary sourceNodes + ) + { + Id = (IdSequence++); + SourceNodes = renderGroup.ToImmutableDictionary(r => r, r => sourceNodes[r]); + + Key = new ProxyNodeKey(filter, renderGroup.Select(r => sourceNodes[r].Id)); + + foreach (var node in SourceNodes.Values) + { + node.InvalidatedTask.ContinueWith(_ => + { + Invalidate(); + }); + } + + using (new SyncContextScope(ReactiveQueryScheduler.SynchronizationContext)) + { + ComputeContext context = new ComputeContext(() => "ProxyNode"); + context.OnInvalidate = InvalidatedTask; + context.Invalidate = () => + { + Invalidate(); + }; + + PrepareTask = Task.Factory.StartNew( + async () => + { + // Wait for all tasks to complete, or for invalidation + var inputTasks = SourceNodes.Values.Select(n => n.PrepareTask).ToList(); + var allInputReady = Task.WhenAll(inputTasks).PreventRecursion(); + await Task.WhenAny(allInputReady, InvalidatedTask); + + if (Invalidated) return null; + + var inputMeshes = SourceNodes.Select(kvp => + { + var node = kvp.Value; + var state = kvp.Value.PrepareTask.Result[kvp.Key]; + + return state.Clone(Id); + }).ToList(); + + await filter.MutateMeshData(inputMeshes, context); + + return inputMeshes.ToImmutableDictionary(m => m.Original); + }, + context.CancellationToken, + TaskCreationOptions.None, + TaskScheduler.FromCurrentSynchronizationContext() + ).Unwrap(); + } + } + + public void Invalidate() + { + _invalidater.TrySetResult(null); + } + + public void Dispose() + { + Invalidate(); + Disposed = true; + + PrepareTask.ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + var value = t.Result; + if (value != null) + { + foreach (var meshState in value.Values) + { + meshState.Dispose(); + } + } + } + }); + + // clear private state? + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/ProxyNode.cs.meta b/Editor/PreviewSystem/Rendering/ProxyNode.cs.meta new file mode 100644 index 00000000..aad98b9b --- /dev/null +++ b/Editor/PreviewSystem/Rendering/ProxyNode.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 650c1fad766d4e43b7fc1b16e6639854 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/ProxyObjectController.cs b/Editor/PreviewSystem/Rendering/ProxyObjectController.cs new file mode 100644 index 00000000..298309c2 --- /dev/null +++ b/Editor/PreviewSystem/Rendering/ProxyObjectController.cs @@ -0,0 +1,145 @@ +#region + +using System; +using System.Linq; +using UnityEngine; +using UnityEngine.SceneManagement; +using Object = UnityEngine.Object; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal class ProxyObjectController : IDisposable + { + private readonly Renderer _originalRenderer; + private Renderer _replacementRenderer; + + internal ProxyPipeline Pipeline { get; set; } + internal Renderer Renderer => _replacementRenderer; + public bool IsValid => _originalRenderer != null && _replacementRenderer != null; + + public ProxyObjectController(Renderer originalRenderer) + { + _originalRenderer = originalRenderer; + + CreateReplacementObject(); + } + + private void UpdateRenderer() + { + MeshState state = Pipeline?.GetState(_originalRenderer); + SkinnedMeshRenderer smr = null; + + if (_replacementRenderer == null || _originalRenderer == null) + { + Pipeline?.Invalidate(); + return; + } + + if (_originalRenderer is SkinnedMeshRenderer smr_) + { + smr = smr_; + + var replacementSMR = (SkinnedMeshRenderer)_replacementRenderer; + replacementSMR.sharedMesh = state?.Mesh ?? smr_.sharedMesh; + replacementSMR.bones = smr_.bones; + } + else + { + var filter = _replacementRenderer.GetComponent(); + filter.sharedMesh = state?.Mesh ?? _originalRenderer.GetComponent().sharedMesh; + } + + _replacementRenderer.sharedMaterials = state?.Materials?.ToArray() ?? _originalRenderer.sharedMaterials; + + var target = _replacementRenderer; + var original = _originalRenderer; + + if (target.gameObject.scene != original.gameObject.scene && + original.gameObject.scene.IsValid()) + { + SceneManager.MoveGameObjectToScene(target.gameObject, original.gameObject.scene); + } + + target.transform.position = original.transform.position; + target.transform.rotation = original.transform.rotation; + + target.localBounds = original.localBounds; + if (target is SkinnedMeshRenderer targetSMR && original is SkinnedMeshRenderer originalSMR) + { + targetSMR.rootBone = originalSMR.rootBone; + targetSMR.quality = originalSMR.quality; + + if (targetSMR.sharedMesh != null) + { + var blendShapeCount = targetSMR.sharedMesh.blendShapeCount; + for (var i = 0; i < blendShapeCount; i++) + { + targetSMR.SetBlendShapeWeight(i, originalSMR.GetBlendShapeWeight(i)); + } + } + } + + target.shadowCastingMode = original.shadowCastingMode; + target.receiveShadows = original.receiveShadows; + target.lightProbeUsage = original.lightProbeUsage; + target.reflectionProbeUsage = original.reflectionProbeUsage; + target.probeAnchor = original.probeAnchor; + target.motionVectorGenerationMode = original.motionVectorGenerationMode; + target.allowOcclusionWhenDynamic = original.allowOcclusionWhenDynamic; + + Pipeline?.RunOnFrame(_originalRenderer, _replacementRenderer); + } + + private bool CreateReplacementObject() + { + var replacementGameObject = new GameObject("Proxy renderer for " + _originalRenderer.gameObject.name); + replacementGameObject.hideFlags = HideFlags.DontSave; + +#if MODULAR_AVATAR_DEBUG_HIDDEN + replacementGameObject.hideFlags = HideFlags.DontSave; +#endif + + replacementGameObject.AddComponent().KeepAlive = this; + + if (_originalRenderer is SkinnedMeshRenderer smr) + { + _replacementRenderer = replacementGameObject.AddComponent(); + } + else if (_originalRenderer is MeshRenderer mr) + { + _replacementRenderer = replacementGameObject.AddComponent(); + replacementGameObject.AddComponent(); + } + else + { + Debug.Log("Unsupported renderer type: " + _replacementRenderer.GetType()); + Object.DestroyImmediate(replacementGameObject); + return true; + } + + return false; + } + + /// + /// + /// + /// (original, replacement renderer) + public (Renderer, Renderer) OnPreCull() + { + UpdateRenderer(); + + return (_originalRenderer, _replacementRenderer); + } + + public void Dispose() + { + if (_replacementRenderer != null) + { + Object.DestroyImmediate(_replacementRenderer.gameObject); + _replacementRenderer = null; + } + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/ProxyObjectController.cs.meta b/Editor/PreviewSystem/Rendering/ProxyObjectController.cs.meta new file mode 100644 index 00000000..89ec2418 --- /dev/null +++ b/Editor/PreviewSystem/Rendering/ProxyObjectController.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 57a5c633bf74444797067c679ce197ac +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/ProxyPipeline.cs b/Editor/PreviewSystem/Rendering/ProxyPipeline.cs new file mode 100644 index 00000000..5f093ec4 --- /dev/null +++ b/Editor/PreviewSystem/Rendering/ProxyPipeline.cs @@ -0,0 +1,196 @@ +#region + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using nadena.dev.ndmf.rq; +using UnityEditor; +using UnityEngine; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal enum PipelineStatus + { + NotReady, + Ready, + Invalidated, + Disposed + } + + /// + /// Represents a single, instantiated pipeline for building and maintaining all proxy objects. + /// + internal class ProxyPipeline + { + private List<(IRenderFilter, ImmutableList)> _filterGroups; + private Dictionary> _rendererToFilters; + private ImmutableDictionary _meshLeaves; + + private TaskCompletionSource _invalidater = new(); + public Task InvalidatedTask => _invalidater.Task; + public bool Invalidated => InvalidatedTask.IsCompleted; + + private Task BuildPipelineTask; + + private bool _disposeCalled; + private Task _disposeTask; + + public bool BuildCompleted => BuildPipelineTask.IsCompleted; + public bool Aborted => BuildPipelineTask.IsCompleted && _meshLeaves == null; + + public IImmutableSet Renderers => _meshLeaves.Keys.ToImmutableHashSet(); + + private IImmutableList Nodes = ImmutableList.Empty; + + public ProxyPipeline(NodeGraph graph, IEnumerable filters) + { + using (new SyncContextScope(ReactiveQueryScheduler.SynchronizationContext)) + { + BuildPipelineTask = Build(graph, filters).ContinueWith(t => + { + if (t.IsFaulted) + { + Debug.LogException(t.Exception); + } + + EditorApplication.delayCall += SceneView.RepaintAll; + }); + } + } + + public void Invalidate() + { + _invalidater.TrySetResult(null); + } + + private async Task Build(NodeGraph graph, IEnumerable filters) + { + var ctx = new ComputeContext(() => "Preview pipeline: Construct pipeline"); + + ctx.Invalidate = () => _invalidater.TrySetResult(null); + ctx.OnInvalidate = InvalidatedTask; + + filters = filters.ToList(); + + _filterGroups = await CollectInterestingRenderers(filters); + + _rendererToFilters = new Dictionary>(); + foreach (var group in _filterGroups) + { + foreach (var renderer in group.Item2) + { + if (!_rendererToFilters.TryGetValue(renderer, out var list)) + { + list = new List(); + _rendererToFilters.Add(renderer, list); + } + + list.Add(group.Item1); + } + } + + var allRenderers = _filterGroups.SelectMany(p => p.Item2).ToHashSet(); + var nodes = ImmutableList.Empty.ToBuilder(); + + var leaves = ImmutableDictionary.Empty; + foreach (var renderer in allRenderers) + { + var node = graph.GetOrCreate(new ProxyNodeKey(renderer), () => new ProxyNode(renderer)); + leaves = leaves.Add(renderer, node); + nodes.Add(node.Key); + _ = node.InvalidatedTask.ContinueWith(_ => Invalidate()); + } + + if (Invalidated) + { + // Abort ASAP + return; + } + + foreach (var pair in _filterGroups) + { + var (filter, sourceRenderers) = pair; + var sources = sourceRenderers.Select(r => leaves[r].Id); + var key = new ProxyNodeKey(filter, sources); + + var node = graph.GetOrCreate(key, () => new ProxyNode(filter, sourceRenderers, leaves)); + nodes.Add(node.Key); + _ = node.InvalidatedTask.ContinueWith(_ => Invalidate()); + + foreach (var source in sourceRenderers) + { + leaves = leaves.SetItem(source, node); + } + } + + _meshLeaves = leaves; + Nodes = nodes.ToImmutable(); + } + + private async Task)>> CollectInterestingRenderers( + IEnumerable filters) + { + var ctx = new ComputeContext(() => "Preview pipeline: Collect interesting renderers"); + + ctx.Invalidate = () => _invalidater.TrySetResult(null); + ctx.OnInvalidate = InvalidatedTask; + + var result = new List<(IRenderFilter, ImmutableList)>(); + foreach (var filter in filters) + { + var groups = await ctx.Observe(filter.TargetGroups); + if (groups.Count == 0) continue; + + // TODO: Validate groups are non-overlapping + + foreach (var group in groups) + { + if (group.Count == 0) continue; + result.Add((filter, group.ToImmutableList())); + } + } + + return result; + } + + + public MeshState GetState(Renderer originalRenderer) + { + if (_disposeCalled) throw new ObjectDisposedException("ProxyPipeline"); + if (!BuildCompleted) throw new InvalidOperationException("Pipeline not ready"); + + if (_meshLeaves.TryGetValue(originalRenderer, out var node)) + { + if (node.PrepareTask.IsCompleted && + node.PrepareTask.Result?.TryGetValue(originalRenderer, out var state) == true) + { + return state; + } + } + + return null; + } + + public void RunOnFrame(Renderer original, Renderer replacement) + { + var filters = _rendererToFilters[original]; + + foreach (var filter in filters) + { + filter.OnFrame(original, replacement); + } + } + + public void CollectNodes(HashSet toRetain) + { + foreach (var node in Nodes) + { + toRetain.Add(node); + } + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/ProxyPipeline.cs.meta b/Editor/PreviewSystem/Rendering/ProxyPipeline.cs.meta new file mode 100644 index 00000000..2807ce81 --- /dev/null +++ b/Editor/PreviewSystem/Rendering/ProxyPipeline.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: cb563be3da564c819f5a651fe11ace1d +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/ProxySession.cs b/Editor/PreviewSystem/Rendering/ProxySession.cs new file mode 100644 index 00000000..ece7a2e4 --- /dev/null +++ b/Editor/PreviewSystem/Rendering/ProxySession.cs @@ -0,0 +1,154 @@ +#region + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using nadena.dev.ndmf.rq; +using UnityEditor; +using UnityEngine; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal class ProxySession : IObserver>, IDisposable + { + private ReactiveValue> _filters; + private NodeGraph _graph = new(); + private ProxyPipeline _active, _next; + + private IDisposable _unsubscribe; + + private Dictionary _proxyControllers = new(); + private List<(Renderer, Renderer)> activeRenderers = new(); + + internal ImmutableDictionary OriginalToProxyRenderer = + ImmutableDictionary.Empty; + + internal ImmutableDictionary OriginalToProxyObject = + ImmutableDictionary.Empty; + + internal ImmutableDictionary ProxyToOriginalObject = + ImmutableDictionary.Empty; + + public ProxySession(ReactiveValue> filters) + { + _filters = filters; + + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + _unsubscribe = filters.Subscribe(this); + } + + private void OnPlayModeStateChanged(PlayModeStateChange obj) + { + _active = _next = null; + _graph.Retain(ImmutableHashSet.Empty); + } + + public void Dispose() + { + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + _unsubscribe?.Dispose(); + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + Debug.LogException(error); + } + + public void OnNext(ImmutableList filters) + { + _active?.Invalidate(); + _next?.Invalidate(); + } + + public IEnumerable<(Renderer, Renderer)> OnPreCull() + { + if (_active == null || _active.Invalidated) + { + if (_next != null) + { + if (_next.Aborted) _next = null; + else if (_next.BuildCompleted) + { + _active = _next; + _next = null; + CollectNodes(); + RebuildRenderers(); + } + } + } + + if (_active == null || _active.Invalidated) + { + if (_next == null && _filters.TryGetValue(out var list)) + { + _next = new ProxyPipeline(_graph, list); + } + } + + if (_active is not { BuildCompleted: true }) + { + return Array.Empty<(Renderer, Renderer)>(); + } + + foreach (var poc in _proxyControllers.Values) + { + poc.OnPreCull(); + } + + return activeRenderers; + } + + private void RebuildRenderers() + { + Dictionary retain = new(); + + activeRenderers.Clear(); + + var originalToProxyObject = ImmutableDictionary.Empty.ToBuilder(); + var proxyToOriginalObject = ImmutableDictionary.Empty.ToBuilder(); + var originalToProxyRenderer = ImmutableDictionary.Empty.ToBuilder(); + + foreach (var srcRenderer in _active.Renderers) + { + if (_proxyControllers.TryGetValue(srcRenderer, out var poc) && poc.IsValid) + { + retain.Add(srcRenderer, poc); + } + else + { + poc = new ProxyObjectController(srcRenderer); + retain.Add(srcRenderer, poc); + } + + poc.Pipeline = _active; + activeRenderers.Add((srcRenderer, poc.Renderer)); + + originalToProxyObject[srcRenderer.gameObject] = poc.Renderer.gameObject; + proxyToOriginalObject[poc.Renderer.gameObject] = srcRenderer.gameObject; + originalToProxyRenderer[srcRenderer] = poc.Renderer; + } + + _proxyControllers = retain; + + OriginalToProxyObject = originalToProxyObject.ToImmutable(); + ProxyToOriginalObject = proxyToOriginalObject.ToImmutable(); + OriginalToProxyRenderer = originalToProxyRenderer.ToImmutable(); + } + + private void CollectNodes() + { + var toRetain = new HashSet(); + + _active?.CollectNodes(toRetain); + _next?.CollectNodes(toRetain); + + _graph.Retain(toRetain); + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Rendering/ProxySession.cs.meta b/Editor/PreviewSystem/Rendering/ProxySession.cs.meta new file mode 100644 index 00000000..22d0f85c --- /dev/null +++ b/Editor/PreviewSystem/Rendering/ProxySession.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: eaaf12270b2d4f9b9c374576380a808f +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/SequencePoint.cs b/Editor/PreviewSystem/SequencePoint.cs new file mode 100644 index 00000000..a25814fc --- /dev/null +++ b/Editor/PreviewSystem/SequencePoint.cs @@ -0,0 +1,23 @@ +namespace nadena.dev.ndmf.preview +{ + /// + /// Describes the time at which a particular mesh operation should be performed. Sequence points are created + /// relative to each other; unrelated sequence points cannot be used in the same PreviewSession. + /// + public sealed class SequencePoint + { + private static int _creationOrder = 0; + + public string DebugString { get; set; } + + public SequencePoint() + { + DebugString = "#" + (_creationOrder++); + } + + public override string ToString() + { + return "[SequencePoint " + DebugString + "]"; + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/SequencePoint.cs.meta b/Editor/PreviewSystem/SequencePoint.cs.meta new file mode 100644 index 00000000..8f7127f5 --- /dev/null +++ b/Editor/PreviewSystem/SequencePoint.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 58a1339b057142b4827c4c24a9735713 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/PreviewSystem/Sequencer.cs b/Editor/PreviewSystem/Sequencer.cs new file mode 100644 index 00000000..3da13801 --- /dev/null +++ b/Editor/PreviewSystem/Sequencer.cs @@ -0,0 +1,58 @@ +#region + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +#endregion + +namespace nadena.dev.ndmf.preview +{ + internal sealed class Sequencer + { + private readonly HashSet _discovered = new(); + private readonly List _discoveryOrder = new(); + private readonly Dictionary _resolvedOrder = new(); + + public IEnumerable Sequence => _resolvedOrder + .OrderBy(kvp => kvp.Value) + .Select(kvp => kvp.Key) + .ToImmutableList(); + + public Sequencer() + { + } + + public void SetSequence(IEnumerable points) + { + _resolvedOrder.Clear(); + + foreach (var point in points) + { + if (_discovered.Add(point)) + { + _discoveryOrder.Add(point); + } + + _resolvedOrder[point] = _resolvedOrder.Count; + } + + foreach (var point in _discoveryOrder) + { + if (!_resolvedOrder.ContainsKey(point)) + { + _resolvedOrder[point] = _resolvedOrder.Count; + } + } + } + + public void AddPoint(SequencePoint point) + { + if (_discovered.Add(point)) + { + _discoveryOrder.Add(point); + _resolvedOrder[point] = _resolvedOrder.Count; + } + } + } +} \ No newline at end of file diff --git a/Editor/PreviewSystem/Sequencer.cs.meta b/Editor/PreviewSystem/Sequencer.cs.meta new file mode 100644 index 00000000..d5e6ba41 --- /dev/null +++ b/Editor/PreviewSystem/Sequencer.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a8baaacd875940c7a84517642cdbb342 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/RQ-Unity.meta b/Editor/RQ-Unity.meta new file mode 100644 index 00000000..ca32b978 --- /dev/null +++ b/Editor/RQ-Unity.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b2611461f0a3404db655377ab50d678c +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/RQ-Unity/CommonQueries.cs b/Editor/RQ-Unity/CommonQueries.cs new file mode 100644 index 00000000..9221025e --- /dev/null +++ b/Editor/RQ-Unity/CommonQueries.cs @@ -0,0 +1,77 @@ +#region + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using UnityEngine; + +#endregion + +namespace nadena.dev.ndmf.rq.unity.editor +{ + public static class CommonQueries + { + /// + /// Reactive value evaluating to a list of all root game objects in all loaded scenes. + /// + public static ReactiveValue> SceneRoots { get; } + = ReactiveValue>.Create("SceneRoots", + ctx => + { + var invalidate = ctx.Invalidate; + var onInvalidate = ctx.OnInvalidate; + + var roots = ObjectWatcher.Instance.MonitorSceneRoots(out var dispose, _ => invalidate(), + onInvalidate); + onInvalidate.ContinueWith(_ => dispose.Dispose()); + + return Task.FromResult(roots); + }); + + private static Dictionary */> _builderCache = new(); + + private static ReactiveQuery> _componentsByType + = new("ComponentsByType", + async (ctx, type) => + { + var roots = await ctx.Observe(SceneRoots); + + IEnumerable components = + roots.SelectMany(root => ctx.GetComponentsInChildren(root, type, true)); + + return components.ToImmutableList(); + }); + + /// + /// Returns a reactive value that evaluates to a list of all components of the given type in the scene. + /// + /// The type to search for + /// + public static ReactiveValue> GetComponentsByType() where T : Component + { + if (!_builderCache.TryGetValue(typeof(T), out var builder)) + { + _builderCache[typeof(T)] = builder = ReactiveValue>.Create( + "ComponentsByType: " + typeof(T), + async ctx => + { + var roots = await ctx.Observe(SceneRoots); + + IEnumerable components = + roots.SelectMany(root => ctx.GetComponentsInChildren(root, true)); + + return components.ToImmutableList(); + }); + } + + return (ReactiveValue>)builder; + } + + public static ReactiveValue> GetComponentsByType(Type type) + { + return _componentsByType.Get(type); + } + } +} \ No newline at end of file diff --git a/Editor/RQ-Unity/CommonQueries.cs.meta b/Editor/RQ-Unity/CommonQueries.cs.meta new file mode 100644 index 00000000..b7b9c5a8 --- /dev/null +++ b/Editor/RQ-Unity/CommonQueries.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4ffdaef8424347af8ab191de633600c7 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/RQ-Unity/ReactiveQueryExt.cs b/Editor/RQ-Unity/ReactiveQueryExt.cs new file mode 100644 index 00000000..79876925 --- /dev/null +++ b/Editor/RQ-Unity/ReactiveQueryExt.cs @@ -0,0 +1,186 @@ +#region + +using System; +using System.Collections.Generic; +using UnityEngine; +using Object = UnityEngine.Object; + +#endregion + +namespace nadena.dev.ndmf.rq.unity.editor +{ + public static class ReactiveQueryExt + { + /// + /// Monitors a given Unity object for changes, and recomputes when changes are detected. + /// + /// This will recompute when properties of the object change, when the object is destroyed, or (in the case of + /// a GameObject), when the children of the GameObject changed. + /// + /// + /// + /// + /// + public static T Observe(this ComputeContext ctx, T obj) where T : Object + { + var invalidate = ctx.Invalidate; + var onInvalidate = ctx.OnInvalidate; + + ObjectWatcher.Instance.MonitorObjectProps(out var dispose, obj, a => a(), invalidate); + onInvalidate.ContinueWith(_ => dispose.Dispose()); + + return obj; + } + + /// + /// Observes the full path from the scene root to the given transform. The calling computation will be + /// re-executed if any of the objects in this path are reparented. + /// + /// + /// + /// An enumerable of transforms in the path, starting from the leaf. + public static IEnumerable ObservePath(this ComputeContext ctx, Transform obj) + { + var invalidate = ctx.Invalidate; + var onInvalidate = ctx.OnInvalidate; + + ObjectWatcher.Instance.MonitorObjectPath(out var dispose, obj, i => i(), invalidate); + onInvalidate.ContinueWith(_ => dispose.Dispose()); + + return FollowPath(obj); + + IEnumerable FollowPath(Transform obj) + { + while (obj != null) + { + yield return obj; + obj = obj.parent; + } + } + } + + /// + /// Observes the world space position of a given transform. + /// + /// + /// + public static Transform ObserveTransformPosition(this ComputeContext ctx, Transform t) + { + foreach (var node in ctx.ObservePath(t)) + { + ctx.Observe(node); + } + + return t; + } + + /// + /// Observes whether a given game object and all its parents are active. + /// + /// + /// + /// + public static bool ActiveInHierarchy(this ComputeContext ctx, GameObject obj) + { + ObservePath(ctx, obj.transform); + return obj.activeInHierarchy; + } + + /// + /// Observes whether a component is enabled, and its heirarchy path is active. + /// + /// + /// + /// + public static bool ActiveAndEnabled(this ComputeContext ctx, Behaviour c) + { + return ActiveInHierarchy(ctx, c.gameObject) && ctx.Observe(c).enabled; + } + + public static C GetComponent(this ComputeContext ctx, GameObject obj) where C : Component + { + var invalidate = ctx.Invalidate; + var onInvalidate = ctx.OnInvalidate; + + var c = ObjectWatcher.Instance.MonitorGetComponent(out var dispose, obj, a => a(), invalidate, + () => obj != null ? obj.GetComponent() : null); + onInvalidate.ContinueWith(_ => dispose.Dispose()); + + return c; + } + + public static Component GetComponent(this ComputeContext ctx, GameObject obj, Type type) + { + if (obj == null) return null; + + var invalidate = ctx.Invalidate; + var onInvalidate = ctx.OnInvalidate; + + var c = ObjectWatcher.Instance.MonitorGetComponent(out var dispose, obj, a => a(), invalidate, + () => obj != null ? obj.GetComponent(type) : (Component)null); + onInvalidate.ContinueWith(_ => dispose.Dispose()); + + return c; + } + + public static C[] GetComponents(this ComputeContext ctx, GameObject obj) where C : Component + { + if (obj == null) return Array.Empty(); + + var invalidate = ctx.Invalidate; + var onInvalidate = ctx.OnInvalidate; + + var c = ObjectWatcher.Instance.MonitorGetComponents(out var dispose, obj, a => a(), invalidate, + () => obj != null ? obj.GetComponents() : Array.Empty(), false); + onInvalidate.ContinueWith(_ => dispose.Dispose()); + + return c; + } + + public static Component[] GetComponents(this ComputeContext ctx, GameObject obj, Type type) + { + if (obj == null) return Array.Empty(); + + var invalidate = ctx.Invalidate; + var onInvalidate = ctx.OnInvalidate; + + var c = ObjectWatcher.Instance.MonitorGetComponents(out var dispose, obj, a => a(), invalidate, + () => obj != null ? obj.GetComponents(type) : Array.Empty(), false); + onInvalidate.ContinueWith(_ => dispose.Dispose()); + + return c; + } + + public static C[] GetComponentsInChildren(this ComputeContext ctx, GameObject obj, bool includeInactive) + where C : Component + { + if (obj == null) return Array.Empty(); + + var invalidate = ctx.Invalidate; + var onInvalidate = ctx.OnInvalidate; + + var c = ObjectWatcher.Instance.MonitorGetComponents(out var dispose, obj, a => a(), invalidate, + () => { return obj != null ? obj.GetComponentsInChildren(includeInactive) : Array.Empty(); }, + true); + onInvalidate.ContinueWith(_ => dispose.Dispose()); + + return c; + } + + public static Component[] GetComponentsInChildren(this ComputeContext ctx, GameObject obj, Type type, + bool includeInactive) + { + if (obj == null) return Array.Empty(); + + var invalidate = ctx.Invalidate; + var onInvalidate = ctx.OnInvalidate; + + var c = ObjectWatcher.Instance.MonitorGetComponents(out var dispose, obj, a => a(), invalidate, + () => obj != null ? obj.GetComponentsInChildren(type, includeInactive) : Array.Empty(), + true); + onInvalidate.ContinueWith(_ => dispose.Dispose()); + + return c; + } + } +} \ No newline at end of file diff --git a/Editor/RQ-Unity/ReactiveQueryExt.cs.meta b/Editor/RQ-Unity/ReactiveQueryExt.cs.meta new file mode 100644 index 00000000..76446718 --- /dev/null +++ b/Editor/RQ-Unity/ReactiveQueryExt.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: bf5ebaec60e5428797358bd2228ed31b +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/RQ-Unity/ThrottledSynchronizationContext.cs b/Editor/RQ-Unity/ThrottledSynchronizationContext.cs new file mode 100644 index 00000000..f6cebc62 --- /dev/null +++ b/Editor/RQ-Unity/ThrottledSynchronizationContext.cs @@ -0,0 +1,208 @@ +#region + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using UnityEditor; +using UnityEngine.Profiling; +using Debug = UnityEngine.Debug; + +#endregion + +namespace nadena.dev.ndmf.rq.unity.editor +{ + internal sealed class ThrottledSynchronizationContext : SynchronizationContext + { + [InitializeOnLoadMethod] + static void Init() + { + ReactiveQueryScheduler.SynchronizationContextOverride.Value + = new ThrottledSynchronizationContext(Current); + } + + private static CustomSampler _tscontext = CustomSampler.Create("ThrottledSynchronizationContext"); + private static CustomSampler _executingTask = CustomSampler.Create("TaskRunning"); + private readonly object _lock = new object(); + private readonly SynchronizationContext _parent; + private Queue _pendingWork = new Queue(); + private int _owningThreadId; + + public int OwningThreadId => _owningThreadId; + + // locked: + private List _remoteWork = new List(); + private bool _isQueued = false; + + private bool IsRunning { get; set; } = false; + + public bool InContext => _owningThreadId == Thread.CurrentThread.ManagedThreadId && IsRunning; + + private bool IsQueued + { + get => _isQueued; + set + { + if (value == _isQueued) + { + return; + } + + _isQueued = value; + if (_isQueued) + { + _parent.Post(RunWithTimeLimit, this); + } + } + } + + public ThrottledSynchronizationContext(SynchronizationContext parent) + { + _parent = parent; + parent.Send(InitThreadId, this); + } + + private static void InitThreadId(object state) + { + var self = (ThrottledSynchronizationContext)state; + self._owningThreadId = Thread.CurrentThread.ManagedThreadId; + } + + private static void RunWithTimeLimit(object state) + { + var self = (ThrottledSynchronizationContext)state; + + lock (self._lock) + { + self.IsQueued = false; + } + + Stopwatch sw = new Stopwatch(); + sw.Start(); + self.RunUntil(() => sw.ElapsedMilliseconds >= 100); + } + + public void RunUntil(Func terminationCondition) + { + if (_owningThreadId != Thread.CurrentThread.ManagedThreadId) + { + throw new InvalidOperationException("Can only be called from the owning thread"); + } + + _tscontext.Begin(); + lock (_lock) + { + IsRunning = true; + _remoteWork.ForEach(_pendingWork.Enqueue); + _remoteWork.Clear(); + } + + using (TaskThrottle.WithThrottleCondition(terminationCondition)) + { + int n = 0; + do + { + _executingTask.Begin(); + _pendingWork.Dequeue().Run(); + _executingTask.End(); + n++; + } while (_pendingWork.Count > 0 && !terminationCondition()); + + /* + if (_pendingWork.Count > 0) + { + Debug.Log("Throttling SynchronizationContext: " + n + " tasks processed, " + _pendingWork.Count + + " remaining"); + } + */ + } + + lock (_lock) + { + IsRunning = false; + if (_pendingWork.Count > 0) + { + IsQueued = true; + } + } + + _tscontext.End(); + } + + public override void Post(SendOrPostCallback d, object state) + { + if (_owningThreadId == Thread.CurrentThread.ManagedThreadId && IsRunning) + { + _pendingWork.Enqueue(new PendingWork(d, state, null)); + } + else + { + lock (_lock) + { + CheckInvocation(); + _remoteWork.Add(new PendingWork(d, state, null)); + IsQueued = true; + } + } + } + + public override void Send(SendOrPostCallback d, object state) + { + if (_owningThreadId == Thread.CurrentThread.ManagedThreadId && IsRunning) + { + d(state); + } + else + { + CheckInvocation(); + var waitHandle = new ManualResetEvent(false); + lock (_lock) + { + _remoteWork.Add(new PendingWork(d, state, waitHandle)); + IsQueued = true; + } + + waitHandle.WaitOne(); + } + } + + private void CheckInvocation() + { + if (Thread.CurrentThread.ManagedThreadId != _owningThreadId) return; + if (Current == this) return; + + Debug.LogWarning( + "Work was enqueued into ThrottledSynchronizationContext from a foreign task. This can result in deadlocks!"); + } + + private class PendingWork + { + public SendOrPostCallback Callback; + public object State; + public ManualResetEvent WaitHandle; + + public PendingWork(SendOrPostCallback callback, object state, ManualResetEvent waitHandle) + { + Callback = callback; + State = state; + WaitHandle = waitHandle; + } + + public void Run() + { + try + { + Callback(State); + } + catch (Exception e) + { + Debug.LogException(e); + } + finally + { + WaitHandle?.Set(); + } + } + } + } +} \ No newline at end of file diff --git a/Editor/RQ-Unity/ThrottledSynchronizationContext.cs.meta b/Editor/RQ-Unity/ThrottledSynchronizationContext.cs.meta new file mode 100644 index 00000000..53195b41 --- /dev/null +++ b/Editor/RQ-Unity/ThrottledSynchronizationContext.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b9ecac1cad4742efb2ce25ec02b2cfb2 +timeCreated: 1717367851 \ No newline at end of file diff --git a/Editor/assembly-info.cs b/Editor/assembly-info.cs index 0143eac5..92ab177a 100644 --- a/Editor/assembly-info.cs +++ b/Editor/assembly-info.cs @@ -5,4 +5,5 @@ #endregion [assembly: InternalsVisibleTo("nadena.dev.ndmf.UnitTests")] +[assembly: InternalsVisibleTo("nadena.dev.ndmf.reactive-query.tests.editor")] [assembly: InternalsVisibleTo("nadena.dev.ndmf.vrchat")] \ No newline at end of file diff --git a/Editor/nadena.dev.ndmf.asmdef b/Editor/nadena.dev.ndmf.asmdef index d91b103f..bf93ca12 100644 --- a/Editor/nadena.dev.ndmf.asmdef +++ b/Editor/nadena.dev.ndmf.asmdef @@ -1,10 +1,12 @@ { "name": "nadena.dev.ndmf", + "rootNamespace": "", "references": [ "VRC.SDKBase", "VRC.SDK3A", "VRC.SDK3A.Editor", - "nadena.dev.ndmf.runtime" + "nadena.dev.ndmf.runtime", + "nadena.dev.ndmf.reactive-query.core" ], "includePlatforms": [ "Editor" diff --git a/RQ-Core.meta b/RQ-Core.meta new file mode 100644 index 00000000..0d6e5996 --- /dev/null +++ b/RQ-Core.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 179694d3a7fe47758ea3178da6fd91b8 +timeCreated: 1717367862 \ No newline at end of file diff --git a/RQ-Core/ComputeContext.cs b/RQ-Core/ComputeContext.cs new file mode 100644 index 00000000..29284ab2 --- /dev/null +++ b/RQ-Core/ComputeContext.cs @@ -0,0 +1,60 @@ +#region + +using System; +using System.Threading; +using System.Threading.Tasks; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + public sealed class BlockingNode + { + private Lazy _description; + private string Description => _description.Value; + + public BlockingNode(Lazy description) + { + _description = description; + } + + void SetBlockingOn(BlockingNode waitingOn, bool isWaiting = true) + { + // TODO + } + } + + public sealed class ComputeContext + { + public BlockingNode BlockingOn { get; } + public Action Invalidate { get; internal set; } = () => { }; + public Task OnInvalidate { get; internal set; } + public CancellationToken CancellationToken { get; internal set; } = CancellationToken.None; + + internal ComputeContext(Func description) + { + BlockingOn = new BlockingNode(new Lazy(description)); + } + + public async Task Observe(ReactiveValue q) + { + // capture the current invalidate function immediately, to avoid infinite invalidate loops + var invalidate = Invalidate; + var ct = CancellationToken; + // Propagate the invalidation to any listeners synchronously on Invalidate. + _ = q.Invalidated.ContinueWith( + _ => invalidate(), ct, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + + var compute = q.RequestCompute(); + + await TaskThrottle.MaybeThrottle(); + await Task.WhenAny(compute, q.Invalidated.ContinueWith(_ => Task.FromCanceled(ct))); + + ct.ThrowIfCancellationRequested(); + return await compute; + } + } +} \ No newline at end of file diff --git a/RQ-Core/ComputeContext.cs.meta b/RQ-Core/ComputeContext.cs.meta new file mode 100644 index 00000000..447b792d --- /dev/null +++ b/RQ-Core/ComputeContext.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3de420e34aa94664bf0502d6d5d12579 +timeCreated: 1714253620 \ No newline at end of file diff --git a/RQ-Core/IInvalidationObserver.cs b/RQ-Core/IInvalidationObserver.cs new file mode 100644 index 00000000..2098240b --- /dev/null +++ b/RQ-Core/IInvalidationObserver.cs @@ -0,0 +1,7 @@ +namespace nadena.dev.ndmf.rq +{ + internal interface IInvalidationObserver + { + public void OnInvalidate(); + } +} \ No newline at end of file diff --git a/RQ-Core/IInvalidationObserver.cs.meta b/RQ-Core/IInvalidationObserver.cs.meta new file mode 100644 index 00000000..499285f8 --- /dev/null +++ b/RQ-Core/IInvalidationObserver.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 06ff8c32244242f38a22a1089e8ce031 +timeCreated: 1714445520 \ No newline at end of file diff --git a/RQ-Core/JetBrainsAnnotations.meta b/RQ-Core/JetBrainsAnnotations.meta new file mode 100644 index 00000000..0a8d6a53 --- /dev/null +++ b/RQ-Core/JetBrainsAnnotations.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 00058e3d0400da44098ae16a010e5792 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/RQ-Core/JetBrainsAnnotations/JetBrainsAnnotations.cs b/RQ-Core/JetBrainsAnnotations/JetBrainsAnnotations.cs new file mode 100644 index 00000000..5b0640dc --- /dev/null +++ b/RQ-Core/JetBrainsAnnotations/JetBrainsAnnotations.cs @@ -0,0 +1,2189 @@ +/* MIT License + +Copyright (c) 2016 JetBrains http://www.jetbrains.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. */ + +#nullable disable + +#region + +using System; +using System.Diagnostics; + +#endregion + +// ReSharper disable UnusedType.Global + +// conflict with UnityEngine.CoreModule annotations +#pragma warning disable 0436 +#pragma warning disable 1591 +// ReSharper disable UnusedMember.Global +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable IntroduceOptionalParameters.Global +// ReSharper disable MemberCanBeProtected.Global +// ReSharper disable InconsistentNaming + +namespace JetBrains.Annotations +{ + /// + /// Indicates that the value of the marked element could be null sometimes, + /// so checking for null is required before its usage. + /// + /// + /// [CanBeNull] object Test() => null; + /// + /// void UseTest() { + /// var p = Test(); + /// var s = p.ToString(); // Warning: Possible 'System.NullReferenceException' + /// } + /// + [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | + AttributeTargets.Delegate | AttributeTargets.Field | AttributeTargets.Event | + AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.GenericParameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class CanBeNullAttribute : Attribute + { + } + + /// + /// Indicates that the value of the marked element can never be null. + /// + /// + /// [NotNull] object Foo() { + /// return null; // Warning: Possible 'null' assignment + /// } + /// + [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | + AttributeTargets.Delegate | AttributeTargets.Field | AttributeTargets.Event | + AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.GenericParameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class NotNullAttribute : Attribute + { + } + + /// + /// Can be applied to symbols of types derived from IEnumerable as well as to symbols of Task + /// and Lazy classes to indicate that the value of a collection item, of the Task.Result property + /// or of the Lazy.Value property can never be null. + /// + /// + /// public void Foo([ItemNotNull]List<string> books) + /// { + /// foreach (var book in books) { + /// if (book != null) // Warning: Expression is always true + /// Console.WriteLine(book.ToUpper()); + /// } + /// } + /// + [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | + AttributeTargets.Delegate | AttributeTargets.Field)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class ItemNotNullAttribute : Attribute + { + } + + /// + /// Can be applied to symbols of types derived from IEnumerable as well as to symbols of Task + /// and Lazy classes to indicate that the value of a collection item, of the Task.Result property + /// or of the Lazy.Value property can be null. + /// + /// + /// public void Foo([ItemCanBeNull]List<string> books) + /// { + /// foreach (var book in books) + /// { + /// // Warning: Possible 'System.NullReferenceException' + /// Console.WriteLine(book.ToUpper()); + /// } + /// } + /// + [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Property | + AttributeTargets.Delegate | AttributeTargets.Field)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class ItemCanBeNullAttribute : Attribute + { + } + + /// + /// Indicates that the marked method builds a string by the format pattern and (optional) arguments. + /// The parameter, which contains the format string, should be given in the constructor. The format string + /// should be in -like form. + /// + /// + /// [StringFormatMethod("message")] + /// void ShowError(string message, params object[] args) { /* do something */ } + /// + /// void Foo() { + /// ShowError("Failed: {0}"); // Warning: Non-existing argument in format string + /// } + /// + [AttributeUsage( + AttributeTargets.Constructor | AttributeTargets.Method | + AttributeTargets.Property | AttributeTargets.Delegate)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class StringFormatMethodAttribute : Attribute + { + /// + /// Specifies which parameter of an annotated method should be treated as the format string. + /// + public StringFormatMethodAttribute([NotNull] string formatParameterName) + { + FormatParameterName = formatParameterName; + } + + [NotNull] public string FormatParameterName { get; } + } + + /// + /// Indicates that the marked parameter is a message template where placeholders are to be replaced by the following arguments + /// in the order in which they appear. + /// + /// + /// void LogInfo([StructuredMessageTemplate]string message, params object[] args) { /* do something */ } + /// + /// void Foo() { + /// LogInfo("User created: {username}"); // Warning: Non-existing argument in format string + /// } + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class StructuredMessageTemplateAttribute : Attribute + { + } + + /// + /// Use this annotation to specify a type that contains static or const fields + /// with values for the annotated property/field/parameter. + /// The specified type will be used to improve completion suggestions. + /// + /// + /// namespace TestNamespace + /// { + /// public class Constants + /// { + /// public static int INT_CONST = 1; + /// public const string STRING_CONST = "1"; + /// } + /// + /// public class Class1 + /// { + /// [ValueProvider("TestNamespace.Constants")] public int myField; + /// public void Foo([ValueProvider("TestNamespace.Constants")] string str) { } + /// + /// public void Test() + /// { + /// Foo(/*try completion here*/);// + /// myField = /*try completion here*/ + /// } + /// } + /// } + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class ValueProviderAttribute : Attribute + { + public ValueProviderAttribute([NotNull] string name) + { + Name = name; + } + + [NotNull] public string Name { get; } + } + + /// + /// Indicates that the integral value falls into the specified interval. + /// It's allowed to specify multiple non-intersecting intervals. + /// Values of interval boundaries are inclusive. + /// + /// + /// void Foo([ValueRange(0, 100)] int value) { + /// if (value == -1) { // Warning: Expression is always 'false' + /// ... + /// } + /// } + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property | + AttributeTargets.Method | AttributeTargets.Delegate, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class ValueRangeAttribute : Attribute + { + public object From { get; } + public object To { get; } + + public ValueRangeAttribute(long from, long to) + { + From = from; + To = to; + } + + public ValueRangeAttribute(ulong from, ulong to) + { + From = from; + To = to; + } + + public ValueRangeAttribute(long value) + { + From = To = value; + } + + public ValueRangeAttribute(ulong value) + { + From = To = value; + } + } + + /// + /// Indicates that the integral value never falls below zero. + /// + /// + /// void Foo([NonNegativeValue] int value) { + /// if (value == -1) { // Warning: Expression is always 'false' + /// ... + /// } + /// } + /// + [AttributeUsage( + AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property | + AttributeTargets.Method | AttributeTargets.Delegate)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class NonNegativeValueAttribute : Attribute + { + } + + /// + /// Indicates that the function argument should be a string literal and match + /// one of the parameters of the caller function. This annotation is used for parameters + /// like 'string paramName' parameter of the constructor. + /// + /// + /// void Foo(string param) { + /// if (param == null) + /// throw new ArgumentNullException("par"); // Warning: Cannot resolve symbol + /// } + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class InvokerParameterNameAttribute : Attribute + { + } + + /// + /// Indicates that the method is contained in a type that implements + /// System.ComponentModel.INotifyPropertyChanged interface and this method + /// is used to notify that some property value changed. + /// + /// + /// The method should be non-static and conform to one of the supported signatures: + /// + /// NotifyChanged(string) + /// NotifyChanged(params string[]) + /// NotifyChanged{T}(Expression{Func{T}}) + /// NotifyChanged{T,U}(Expression{Func{T,U}}) + /// SetProperty{T}(ref T, T, string) + /// + /// + /// + /// public class Foo : INotifyPropertyChanged { + /// public event PropertyChangedEventHandler PropertyChanged; + /// + /// [NotifyPropertyChangedInvocator] + /// protected virtual void NotifyChanged(string propertyName) { ... } + /// + /// string _name; + /// + /// public string Name { + /// get { return _name; } + /// set { _name = value; NotifyChanged("LastName"); /* Warning */ } + /// } + /// } + /// + /// Examples of generated notifications: + /// + /// NotifyChanged("Property") + /// NotifyChanged(() => Property) + /// NotifyChanged((VM x) => x.Property) + /// SetProperty(ref myField, value, "Property") + /// + /// + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class NotifyPropertyChangedInvocatorAttribute : Attribute + { + public NotifyPropertyChangedInvocatorAttribute() + { + } + + public NotifyPropertyChangedInvocatorAttribute([NotNull] string parameterName) + { + ParameterName = parameterName; + } + + [CanBeNull] public string ParameterName { get; } + } + + /// + /// Describes dependence between method input and output. + /// + /// + ///

Function Definition Table syntax:

+ /// + /// FDT ::= FDTRow [;FDTRow]* + /// FDTRow ::= Input => Output | Output <= Input + /// Input ::= ParameterName: Value [, Input]* + /// Output ::= [ParameterName: Value]* {halt|stop|void|nothing|Value} + /// Value ::= true | false | null | notnull | canbenull + /// + /// If the method has a single input parameter, its name could be omitted.
+ /// Using halt (or void/nothing, which is the same) for the method output + /// means that the method doesn't return normally (throws or terminates the process).
+ /// Value canbenull is only applicable for output parameters.
+ /// You can use multiple [ContractAnnotation] for each FDT row, or use single attribute + /// with rows separated by the semicolon. There is no notion of order rows, all rows are checked + /// for applicability and applied per each program state tracked by the analysis engine.
+ ///
+ /// + /// + /// [ContractAnnotation("=> halt")] + /// public void TerminationMethod() + /// + /// + /// [ContractAnnotation("null <= param:null")] // reverse condition syntax + /// public string GetName(string surname) + /// + /// + /// [ContractAnnotation("s:null => true")] + /// public bool IsNullOrEmpty(string s) // string.IsNullOrEmpty() + /// + /// + /// // A method that returns null if the parameter is null, + /// // and not null if the parameter is not null + /// [ContractAnnotation("null => null; notnull => notnull")] + /// public object Transform(object data) + /// + /// + /// [ContractAnnotation("=> true, result: notnull; => false, result: null")] + /// public bool TryParse(string s, out Person result) + /// + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class ContractAnnotationAttribute : Attribute + { + public ContractAnnotationAttribute([NotNull] string contract) + : this(contract, false) + { + } + + public ContractAnnotationAttribute([NotNull] string contract, bool forceFullStates) + { + Contract = contract; + ForceFullStates = forceFullStates; + } + + [NotNull] public string Contract { get; } + + public bool ForceFullStates { get; } + } + + /// + /// Indicates whether the marked element should be localized. + /// + /// + /// [LocalizationRequiredAttribute(true)] + /// class Foo { + /// string str = "my string"; // Warning: Localizable string + /// } + /// + [AttributeUsage(AttributeTargets.All)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class LocalizationRequiredAttribute : Attribute + { + public LocalizationRequiredAttribute() : this(true) + { + } + + public LocalizationRequiredAttribute(bool required) + { + Required = required; + } + + public bool Required { get; } + } + + /// + /// Indicates that the value of the marked type (or its derivatives) + /// cannot be compared using '==' or '!=' operators and Equals() + /// should be used instead. However, using '==' or '!=' for comparison + /// with null is always permitted. + /// + /// + /// [CannotApplyEqualityOperator] + /// class NoEquality { } + /// + /// class UsesNoEquality { + /// void Test() { + /// var ca1 = new NoEquality(); + /// var ca2 = new NoEquality(); + /// if (ca1 != null) { // OK + /// bool condition = ca1 == ca2; // Warning + /// } + /// } + /// } + /// + [AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class | AttributeTargets.Struct)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class CannotApplyEqualityOperatorAttribute : Attribute + { + } + + /// + /// When applied to a target attribute, specifies a requirement for any type marked + /// with the target attribute to implement or inherit the specific type or types. + /// + /// + /// [BaseTypeRequired(typeof(IComponent)] // Specify requirement + /// class ComponentAttribute : Attribute { } + /// + /// [Component] // ComponentAttribute requires implementing IComponent interface + /// class MyComponent : IComponent { } + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + [BaseTypeRequired(typeof(Attribute))] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class BaseTypeRequiredAttribute : Attribute + { + public BaseTypeRequiredAttribute([NotNull] Type baseType) + { + BaseType = baseType; + } + + [NotNull] public Type BaseType { get; } + } + + /// + /// Indicates that the marked symbol is used implicitly (e.g. via reflection, in external library), + /// so this symbol will be ignored by usage-checking inspections.
+ /// You can use and + /// to configure how this attribute is applied. + ///
+ /// + /// [UsedImplicitly] + /// public class TypeConverter {} + /// + /// public class SummaryData + /// { + /// [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + /// public SummaryData() {} + /// } + /// + /// [UsedImplicitly(ImplicitUseTargetFlags.WithInheritors | ImplicitUseTargetFlags.Default)] + /// public interface IService {} + /// + [AttributeUsage(AttributeTargets.All)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class UsedImplicitlyAttribute : Attribute + { + public UsedImplicitlyAttribute() + : this(ImplicitUseKindFlags.Default, ImplicitUseTargetFlags.Default) + { + } + + public UsedImplicitlyAttribute(ImplicitUseKindFlags useKindFlags) + : this(useKindFlags, ImplicitUseTargetFlags.Default) + { + } + + public UsedImplicitlyAttribute(ImplicitUseTargetFlags targetFlags) + : this(ImplicitUseKindFlags.Default, targetFlags) + { + } + + public UsedImplicitlyAttribute(ImplicitUseKindFlags useKindFlags, ImplicitUseTargetFlags targetFlags) + { + UseKindFlags = useKindFlags; + TargetFlags = targetFlags; + } + + public ImplicitUseKindFlags UseKindFlags { get; } + + public ImplicitUseTargetFlags TargetFlags { get; } + } + + /// + /// Can be applied to attributes, type parameters, and parameters of a type assignable from . + /// When applied to an attribute, the decorated attribute behaves the same as . + /// When applied to a type parameter or to a parameter of type , + /// indicates that the corresponding type is used implicitly. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.GenericParameter | AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class MeansImplicitUseAttribute : Attribute + { + public MeansImplicitUseAttribute() + : this(ImplicitUseKindFlags.Default, ImplicitUseTargetFlags.Default) + { + } + + public MeansImplicitUseAttribute(ImplicitUseKindFlags useKindFlags) + : this(useKindFlags, ImplicitUseTargetFlags.Default) + { + } + + public MeansImplicitUseAttribute(ImplicitUseTargetFlags targetFlags) + : this(ImplicitUseKindFlags.Default, targetFlags) + { + } + + public MeansImplicitUseAttribute(ImplicitUseKindFlags useKindFlags, ImplicitUseTargetFlags targetFlags) + { + UseKindFlags = useKindFlags; + TargetFlags = targetFlags; + } + + [UsedImplicitly] public ImplicitUseKindFlags UseKindFlags { get; } + + [UsedImplicitly] public ImplicitUseTargetFlags TargetFlags { get; } + } + + /// + /// Specifies the details of an implicitly used symbol when it is marked + /// with or . + /// + [Flags] + internal enum ImplicitUseKindFlags + { + Default = Access | Assign | InstantiatedWithFixedConstructorSignature, + + /// Only entity marked with attribute considered used. + Access = 1, + + /// Indicates implicit assignment to a member. + Assign = 2, + + /// + /// Indicates implicit instantiation of a type with fixed constructor signature. + /// That means any unused constructor parameters won't be reported as such. + /// + InstantiatedWithFixedConstructorSignature = 4, + + /// Indicates implicit instantiation of a type. + InstantiatedNoFixedConstructorSignature = 8, + } + + /// + /// Specifies what is considered to be used implicitly when marked + /// with or . + /// + [Flags] + internal enum ImplicitUseTargetFlags + { + Default = Itself, + Itself = 1, + + /// Members of the type marked with the attribute are considered used. + Members = 2, + + /// Inherited entities are considered used. + WithInheritors = 4, + + /// Entity marked with the attribute and all its members considered used. + WithMembers = Itself | Members + } + + /// + /// This attribute is intended to mark publicly available APIs, + /// which should not be removed and so is treated as used. + /// + [MeansImplicitUse(ImplicitUseTargetFlags.WithMembers)] + [AttributeUsage(AttributeTargets.All, Inherited = false)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class PublicAPIAttribute : Attribute + { + public PublicAPIAttribute() + { + } + + public PublicAPIAttribute([NotNull] string comment) + { + Comment = comment; + } + + [CanBeNull] public string Comment { get; } + } + + /// + /// Tells the code analysis engine if the parameter is completely handled when the invoked method is on stack. + /// If the parameter is a delegate, indicates that the delegate can only be invoked during method execution + /// (the delegate can be invoked zero or multiple times, but not stored to some field and invoked later, + /// when the containing method is no longer on the execution stack). + /// If the parameter is an enumerable, indicates that it is enumerated while the method is executed. + /// If is true, the attribute will only take effect if the method invocation is located under the 'await' expression. + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class InstantHandleAttribute : Attribute + { + /// + /// Require the method invocation to be used under the 'await' expression for this attribute to take effect on the code analysis engine. + /// Can be used for delegate/enumerable parameters of 'async' methods. + /// + public bool RequireAwait { get; set; } + } + + /// + /// Indicates that a method does not make any observable state changes. + /// The same as System.Diagnostics.Contracts.PureAttribute. + /// + /// + /// [Pure] int Multiply(int x, int y) => x * y; + /// + /// void M() { + /// Multiply(123, 42); // Warning: Return value of pure method is not used + /// } + /// + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class PureAttribute : Attribute + { + } + + /// + /// Indicates that the return value of the method invocation must be used. + /// + /// + /// Methods decorated with this attribute (in contrast to pure methods) might change state, + /// but make no sense without using their return value.
+ /// Similarly to , this attribute + /// will help to detect usages of the method when the return value is not used. + /// Optionally, you can specify a message to use when showing warnings, e.g. + /// [MustUseReturnValue("Use the return value to...")]. + ///
+ [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class MustUseReturnValueAttribute : Attribute + { + public MustUseReturnValueAttribute() + { + } + + public MustUseReturnValueAttribute([NotNull] string justification) + { + Justification = justification; + } + + [CanBeNull] public string Justification { get; } + } + + /// + /// Indicates that the resource disposal must be handled by the use site, + /// meaning that the resource ownership is transferred to the callee. + /// This annotation can be used to annotate disposable types or their constructors individually to enable + /// the resource disposal IDE code analysis in every context where the new instance of this type is created. + /// Factory methods and 'out' parameters can also be annotated to indicate that the return value of disposable type + /// needs handling. + /// + /// + /// Annotation of input parameters with this attribute is meaningless.
+ /// Constructors inherit this attribute from their type, if it is annotated, + /// but not from the base constructors they delegate to (if any).
+ /// Resource disposal is expected to be expressed via either using (resource) statement, + /// using var declaration, explicit 'Dispose' method call, or an argument passing + /// to a parameter with the attribute applied. + ///
+ [AttributeUsage( + AttributeTargets.Class | AttributeTargets.Constructor | AttributeTargets.Method | AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class MustDisposeResourceAttribute : Attribute + { + public MustDisposeResourceAttribute() + { + Value = true; + } + + public MustDisposeResourceAttribute(bool value) + { + Value = value; + } + + /// + /// When set to false, disposing of the resource is not obligatory. + /// The main use-case for explicit [MustDisposeResource(false)] annotation is to loosen inherited annotation. + /// + public bool Value { get; } + } + + /// + /// Indicates that method or class instance acquires resource ownership and will dispose it after use. + /// + /// + /// Annotation of 'out' parameter with this attribute is meaningless.
+ /// When a instance method is annotated with this attribute, + /// it means that it is handling the resource disposal of the corresponding resource instance.
+ /// When a field or a property is annotated with this attribute, it means that this type owns the resource + /// and will handle the resource disposal properly (e.g. in own IDisposable implementation). + ///
+ [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class HandlesResourceDisposalAttribute : Attribute + { + } + + /// + /// This annotation allows to enforce allocation-less usage patterns of delegates for performance-critical APIs. + /// When this annotation is applied to the parameter of delegate type, the IDE checks the input argument of this parameter: + /// * When a lambda expression or anonymous method is passed as an argument, the IDE verifies that the passed closure + /// has no captures of the containing local variables and the compiler is able to cache the delegate instance + /// to avoid heap allocations. Otherwise a warning is produced. + /// * The IDE warns when the method name or local function name is passed as an argument as this always results + /// in heap allocation of the delegate instance. + /// + /// + /// In C# 9.0+ code, the IDE will also suggest to annotate the anonymous function with the 'static' modifier + /// to make use of the similar analysis provided by the language/compiler. + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RequireStaticDelegateAttribute : Attribute + { + public bool IsError { get; set; } + } + + /// + /// Indicates the type member or parameter of some type that should be used instead of all other ways + /// to get the value of that type. This annotation is useful when you have some "context" value evaluated + /// and stored somewhere, meaning that all other ways to get this value must be consolidated with the existing one. + /// + /// + /// class Foo { + /// [ProvidesContext] IBarService _barService = ...; + /// + /// void ProcessNode(INode node) { + /// DoSomething(node, node.GetGlobalServices().Bar); + /// // ^ Warning: use value of '_barService' field + /// } + /// } + /// + [AttributeUsage( + AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Parameter | AttributeTargets.Method | + AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct | + AttributeTargets.GenericParameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class ProvidesContextAttribute : Attribute + { + } + + /// + /// Indicates that a parameter is a path to a file or a folder within a web project. + /// Path can be relative or absolute, starting from web root (~). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class PathReferenceAttribute : Attribute + { + public PathReferenceAttribute() + { + } + + public PathReferenceAttribute([NotNull, PathReference] string basePath) + { + BasePath = basePath; + } + + [CanBeNull] public string BasePath { get; } + } + + /// + /// An extension method marked with this attribute is processed by code completion + /// as a 'Source Template'. When the extension method is completed over some expression, its source code + /// is automatically expanded like a template at the call site. + /// + /// + /// Template method bodies can contain valid source code and/or special comments starting with '$'. + /// Text inside these comments is added as source code when the template is applied. Template parameters + /// can be used either as additional method parameters or as identifiers wrapped in two '$' signs. + /// Use the attribute to specify macros for parameters. + /// The expression to be used in the expansion can be adjusted by the parameter. + /// + /// + /// In this example, the 'forEach' method is a source template available over all values + /// of enumerable types, producing ordinary C# 'foreach' statement and placing the caret inside the block: + /// + /// [SourceTemplate] + /// public static void forEach<T>(this IEnumerable<T> xs) { + /// foreach (var x in xs) { + /// //$ $END$ + /// } + /// } + /// + /// + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class SourceTemplateAttribute : Attribute + { + /// + /// Allows specifying which expression to capture for template execution if more than one present on the expansion. + /// If not specified, is assumed. + /// + public SourceTemplateTargetExpression Target { get; set; } + } + + /// + /// Provides a value for the to define how to capture + /// the expression at the point of expansion + /// + internal enum SourceTemplateTargetExpression + { + /// Selects inner expression + /// value > 42.{caret} captures 42 + /// _args = args.{caret} captures args + Inner = 0, + + /// Selects outer expression + /// value > 42.{caret} captures value > 42 + /// _args = args.{caret} captures whole assignment + Outer = 1 + } + + /// + /// Allows specifying a macro for a parameter of a source template. + /// + /// + /// You can apply the attribute on the whole method or on any of its additional parameters. The macro expression + /// is defined in the property. When applied on a method, the target + /// template parameter is defined in the property. To apply the macro silently + /// for the parameter, set the property value to -1. + /// + /// + /// Applying the attribute on a source template method: + /// + /// [SourceTemplate, Macro(Target = "item", Expression = "suggestVariableName()")] + /// public static void forEach<T>(this IEnumerable<T> collection) { + /// foreach (var item in collection) { + /// //$ $END$ + /// } + /// } + /// + /// Applying the attribute on a template method parameter: + /// + /// [SourceTemplate] + /// public static void something(this Entity x, [Macro(Expression = "guid()", Editable = -1)] string newguid) { + /// /*$ var $x$Id = "$newguid$" + x.ToString(); + /// x.DoSomething($x$Id); */ + /// } + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method, AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class MacroAttribute : Attribute + { + /// + /// Allows specifying a macro that will be executed for a source template + /// parameter when the template is expanded. + /// + [CanBeNull] + public string Expression { get; set; } + + /// + /// Allows specifying which occurrence of the target parameter becomes editable when the template is deployed. + /// + /// + /// If the target parameter is used several times in the template, only one occurrence becomes editable; + /// other occurrences are changed synchronously. To specify the zero-based index of the editable occurrence, + /// use values >= 0. To make the parameter non-editable when the template is expanded, use -1. + /// + public int Editable { get; set; } + + /// + /// Identifies the target parameter of a source template if the + /// is applied on a template method. + /// + [CanBeNull] + public string Target { get; set; } + } + + /// + /// Indicates how a method, constructor invocation, or property access + /// over a collection type affects the contents of the collection. + /// When applied to a return value of a method, indicates if the returned collection + /// is created exclusively for the caller (CollectionAccessType.UpdatedContent) or + /// can be read/updated from outside (CollectionAccessType.Read | CollectionAccessType.UpdatedContent) + /// Use to specify the access type. + /// + /// + /// Using this attribute only makes sense if all collection methods are marked with this attribute. + /// + /// + /// public class MyStringCollection : List<string> + /// { + /// [CollectionAccess(CollectionAccessType.Read)] + /// public string GetFirstString() + /// { + /// return this.ElementAt(0); + /// } + /// } + /// class Test + /// { + /// public void Foo() + /// { + /// // Warning: Contents of the collection is never updated + /// var col = new MyStringCollection(); + /// string x = col.GetFirstString(); + /// } + /// } + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Property | + AttributeTargets.ReturnValue)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class CollectionAccessAttribute : Attribute + { + public CollectionAccessAttribute(CollectionAccessType collectionAccessType) + { + CollectionAccessType = collectionAccessType; + } + + public CollectionAccessType CollectionAccessType { get; } + } + + /// + /// Provides a value for the to define + /// how the collection method invocation affects the contents of the collection. + /// + [Flags] + internal enum CollectionAccessType + { + /// Method does not use or modify content of the collection. + None = 0, + + /// Method only reads content of the collection but does not modify it. + Read = 1, + + /// Method can change content of the collection but does not add new elements. + ModifyExistingContent = 2, + + /// Method can add new elements to the collection. + UpdatedContent = ModifyExistingContent | 4 + } + + /// + /// Indicates that the marked method is an assertion method, i.e. it halts the control flow if + /// one of the conditions is satisfied. To set the condition, mark one of the parameters with + /// attribute. + /// + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AssertionMethodAttribute : Attribute + { + } + + /// + /// Indicates the condition parameter of the assertion method. The method itself should be + /// marked by the attribute. The mandatory argument of + /// the attribute is the assertion type. + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AssertionConditionAttribute : Attribute + { + public AssertionConditionAttribute(AssertionConditionType conditionType) + { + ConditionType = conditionType; + } + + public AssertionConditionType ConditionType { get; } + } + + /// + /// Specifies the assertion type. If the assertion method argument satisfies the condition, + /// then the execution continues. Otherwise, execution is assumed to be halted. + /// + internal enum AssertionConditionType + { + /// Marked parameter should be evaluated to true. + IS_TRUE = 0, + + /// Marked parameter should be evaluated to false. + IS_FALSE = 1, + + /// Marked parameter should be evaluated to null value. + IS_NULL = 2, + + /// Marked parameter should be evaluated to not null value. + IS_NOT_NULL = 3, + } + + /// + /// Indicates that the marked method unconditionally terminates control flow execution. + /// For example, it could unconditionally throw an exception. + /// + [Obsolete("Use [ContractAnnotation('=> halt')] instead")] + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class TerminatesProgramAttribute : Attribute + { + } + + /// + /// Indicates that the method is a pure LINQ method, with postponed enumeration (like Enumerable.Select, + /// .Where). This annotation allows inference of [InstantHandle] annotation for parameters + /// of delegate type by analyzing LINQ method chains. + /// + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class LinqTunnelAttribute : Attribute + { + } + + /// + /// Indicates that IEnumerable passed as a parameter is not enumerated. + /// Use this annotation to suppress the 'Possible multiple enumeration of IEnumerable' inspection. + /// + /// + /// static void ThrowIfNull<T>([NoEnumeration] T v, string n) where T : class + /// { + /// // custom check for null but no enumeration + /// } + /// + /// void Foo(IEnumerable<string> values) + /// { + /// ThrowIfNull(values, nameof(values)); + /// var x = values.ToList(); // No warnings about multiple enumeration + /// } + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class NoEnumerationAttribute : Attribute + { + } + + /// + /// Indicates that the marked parameter, field, or property is a regular expression pattern. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RegexPatternAttribute : Attribute + { + } + + /// + /// Language of injected code fragment inside marked by the string literal. + /// + internal enum InjectedLanguage + { + CSS = 0, + HTML = 1, + JAVASCRIPT = 2, + JSON = 3, + XML = 4 + } + + /// + /// Indicates that the marked parameter, field, or property is accepting a string literal + /// containing code fragments in a specified language. + /// + /// + /// void Foo([LanguageInjection(InjectedLanguage.CSS, Prefix = "body{", Suffix = "}")] string cssProps) + /// { + /// // cssProps should only contains a list of CSS properties + /// } + /// + /// + /// void Bar([LanguageInjection("json")] string json) + /// { + /// } + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class LanguageInjectionAttribute : Attribute + { + public LanguageInjectionAttribute(InjectedLanguage injectedLanguage) + { + InjectedLanguage = injectedLanguage; + } + + public LanguageInjectionAttribute([NotNull] string injectedLanguage) + { + InjectedLanguageName = injectedLanguage; + } + + /// Specifies a language of the injected code fragment. + public InjectedLanguage InjectedLanguage { get; } + + /// Specifies a language name of the injected code fragment. + [CanBeNull] + public string InjectedLanguageName { get; } + + /// Specifies a string that "precedes" the injected string literal. + [CanBeNull] + public string Prefix { get; set; } + + /// Specifies a string that "follows" the injected string literal. + [CanBeNull] + public string Suffix { get; set; } + } + + /// + /// Prevents the Member Reordering feature from tossing members of the marked class. + /// + /// + /// The attribute must be mentioned in your member reordering patterns. + /// + [AttributeUsage( + AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct | AttributeTargets.Enum, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class NoReorderAttribute : Attribute + { + } + + /// + /// + /// Defines the code search template using the Structural Search and Replace syntax. + /// It allows you to find and, if necessary, replace blocks of code that match a specific pattern. + /// Search and replace patterns consist of a textual part and placeholders. + /// Textural part must contain only identifiers allowed in the target language and will be matched exactly (white spaces, tabulation characters, and line breaks are ignored). + /// Placeholders allow matching variable parts of the target code blocks. + /// A placeholder has the following format: $placeholder_name$- where placeholder_name is an arbitrary identifier. + /// + /// + /// Available placeholders: + /// + /// $this$ - expression of containing type + /// $thisType$ - containing type + /// $member$ - current member placeholder + /// $qualifier$ - this placeholder is available in the replace pattern and can be used to insert a qualifier expression matched by the $member$ placeholder. + /// (Note that if $qualifier$ placeholder is used, then $member$ placeholder will match only qualified references) + /// $expression$ - expression of any type + /// $identifier$ - identifier placeholder + /// $args$ - any number of arguments + /// $arg$ - single argument + /// $arg1$ ... $arg10$ - single argument + /// $stmts$ - any number of statements + /// $stmt$ - single statement + /// $stmt1$ ... $stmt10$ - single statement + /// $name{Expression, 'Namespace.FooType'}$ - expression with 'Namespace.FooType' type + /// $expression{'Namespace.FooType'}$ - expression with 'Namespace.FooType' type + /// $name{Type, 'Namespace.FooType'}$ - 'Namespace.FooType' type + /// $type{'Namespace.FooType'}$ - 'Namespace.FooType' type + /// $statement{1,2}$ - 1 or 2 statements + /// + /// + /// + /// Note that you can also define your own placeholders of the supported types and specify arguments for each placeholder type. + /// This can be done using the following format: $name{type, arguments}$. Where 'name' - is the name of your placeholder, + /// 'type' - is the type of your placeholder (one of the following: Expression, Type, Identifier, Statement, Argument, Member), + /// 'arguments' - arguments list for your placeholder. Each placeholder type supports its own arguments, check examples below for more details. + /// The placeholder type may be omitted and determined from the placeholder name, if the name has one of the following prefixes: + /// + /// expr, expression - expression placeholder, e.g. $exprPlaceholder{}$, $expressionFoo{}$ + /// arg, argument - argument placeholder, e.g. $argPlaceholder{}$, $argumentFoo{}$ + /// ident, identifier - identifier placeholder, e.g. $identPlaceholder{}$, $identifierFoo{}$ + /// stmt, statement - statement placeholder, e.g. $stmtPlaceholder{}$, $statementFoo{}$ + /// type - type placeholder, e.g. $typePlaceholder{}$, $typeFoo{}$ + /// member - member placeholder, e.g. $memberPlaceholder{}$, $memberFoo{}$ + /// + /// + /// + /// Expression placeholder arguments: + /// + /// expressionType - string value in single quotes, specifies full type name to match (empty string by default) + /// exactType - boolean value, specifies if expression should have exact type match (false by default) + /// + /// Examples: + /// + /// $myExpr{Expression, 'Namespace.FooType', true}$ - defines expression placeholder, matching expressions of the 'Namespace.FooType' type with exact matching. + /// $myExpr{Expression, 'Namespace.FooType'}$ - defines expression placeholder, matching expressions of the 'Namespace.FooType' type or expressions which can be implicitly converted to 'Namespace.FooType'. + /// $myExpr{Expression}$ - defines expression placeholder, matching expressions of any type. + /// $exprFoo{'Namespace.FooType', true}$ - defines expression placeholder, matching expressions of the 'Namespace.FooType' type with exact matching. + /// + /// + /// + /// Type placeholder arguments: + /// + /// type - string value in single quotes, specifies full type name to match (empty string by default) + /// exactType - boolean value, specifies if expression should have exact type match (false by default) + /// + /// Examples: + /// + /// $myType{Type, 'Namespace.FooType', true}$ - defines type placeholder, matching 'Namespace.FooType' types with exact matching. + /// $myType{Type, 'Namespace.FooType'}$ - defines type placeholder, matching 'Namespace.FooType' types or types, which can be implicitly converted to 'Namespace.FooType'. + /// $myType{Type}$ - defines type placeholder, matching any type. + /// $typeFoo{'Namespace.FooType', true}$ - defines types placeholder, matching 'Namespace.FooType' types with exact matching. + /// + /// + /// + /// Identifier placeholder arguments: + /// + /// nameRegex - string value in single quotes, specifies regex to use for matching (empty string by default) + /// nameRegexCaseSensitive - boolean value, specifies if name regex is case sensitive (true by default) + /// type - string value in single quotes, specifies full type name to match (empty string by default) + /// exactType - boolean value, specifies if expression should have exact type match (false by default) + /// + /// Examples: + /// + /// $myIdentifier{Identifier, 'my.*', false, 'Namespace.FooType', true}$ - defines identifier placeholder, matching identifiers (ignoring case) starting with 'my' prefix with 'Namespace.FooType' type. + /// $myIdentifier{Identifier, 'my.*', true, 'Namespace.FooType', true}$ - defines identifier placeholder, matching identifiers (case sensitively) starting with 'my' prefix with 'Namespace.FooType' type. + /// $identFoo{'my.*'}$ - defines identifier placeholder, matching identifiers (case sensitively) starting with 'my' prefix. + /// + /// + /// + /// Statement placeholder arguments: + /// + /// minimalOccurrences - minimal number of statements to match (-1 by default) + /// maximalOccurrences - maximal number of statements to match (-1 by default) + /// + /// Examples: + /// + /// $myStmt{Statement, 1, 2}$ - defines statement placeholder, matching 1 or 2 statements. + /// $myStmt{Statement}$ - defines statement placeholder, matching any number of statements. + /// $stmtFoo{1, 2}$ - defines statement placeholder, matching 1 or 2 statements. + /// + /// + /// + /// Argument placeholder arguments: + /// + /// minimalOccurrences - minimal number of arguments to match (-1 by default) + /// maximalOccurrences - maximal number of arguments to match (-1 by default) + /// + /// Examples: + /// + /// $myArg{Argument, 1, 2}$ - defines argument placeholder, matching 1 or 2 arguments. + /// $myArg{Argument}$ - defines argument placeholder, matching any number of arguments. + /// $argFoo{1, 2}$ - defines argument placeholder, matching 1 or 2 arguments. + /// + /// + /// + /// Member placeholder arguments: + /// + /// docId - string value in single quotes, specifies XML documentation id of the member to match (empty by default) + /// + /// Examples: + /// + /// $myMember{Member, 'M:System.String.IsNullOrEmpty(System.String)'}$ - defines member placeholder, matching 'IsNullOrEmpty' member of the 'System.String' type. + /// $memberFoo{'M:System.String.IsNullOrEmpty(System.String)'}$ - defines member placeholder, matching 'IsNullOrEmpty' member of the 'System.String' type. + /// + /// + /// + /// For more information please refer to the Structural Search and Replace article. + /// + /// + [AttributeUsage( + AttributeTargets.Method + | AttributeTargets.Constructor + | AttributeTargets.Property + | AttributeTargets.Field + | AttributeTargets.Event + | AttributeTargets.Interface + | AttributeTargets.Class + | AttributeTargets.Struct + | AttributeTargets.Enum, + AllowMultiple = true, + Inherited = false)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class CodeTemplateAttribute : Attribute + { + public CodeTemplateAttribute(string searchTemplate) + { + SearchTemplate = searchTemplate; + } + + /// + /// Structural search pattern to use in the code template. + /// The pattern includes a textual part, which must contain only identifiers allowed in the target language, + /// and placeholders, which allow matching variable parts of the target code blocks. + /// + public string SearchTemplate { get; } + + /// + /// Message to show when the search pattern was found. + /// You can also prepend the message text with "Error:", "Warning:", "Suggestion:" or "Hint:" prefix to specify the pattern severity. + /// Code patterns with replace templates produce suggestions by default. + /// However, if a replace template is not provided, then warning severity will be used. + /// + public string Message { get; set; } + + /// + /// Structural search replace pattern to use in code template replacement. + /// + public string ReplaceTemplate { get; set; } + + /// + /// The replace message to show in the light bulb. + /// + public string ReplaceMessage { get; set; } + + /// + /// Apply code formatting after code replacement. + /// + public bool FormatAfterReplace { get; set; } = true; + + /// + /// Whether similar code blocks should be matched. + /// + public bool MatchSimilarConstructs { get; set; } + + /// + /// Automatically insert namespace import directives or remove qualifiers that become redundant after the template is applied. + /// + public bool ShortenReferences { get; set; } + + /// + /// The string to use as a suppression key. + /// By default the following suppression key is used 'CodeTemplate_SomeType_SomeMember', + /// where 'SomeType' and 'SomeMember' are names of the associated containing type and member to which this attribute is applied. + /// + public string SuppressionKey { get; set; } + } + + /// + /// Indicates that the string literal, passed as an argument matching this parameter, + /// should not be checked on spelling or grammar errors. + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class IgnoreSpellingAndGrammarErrorsAttribute : Attribute + { + } + + #region ASP.NET + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspChildControlTypeAttribute : Attribute + { + public AspChildControlTypeAttribute([NotNull] string tagName, [NotNull] Type controlType) + { + TagName = tagName; + ControlType = controlType; + } + + [NotNull] public string TagName { get; } + + [NotNull] public Type ControlType { get; } + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspDataFieldAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspDataFieldsAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMethodPropertyAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspRequiredAttributeAttribute : Attribute + { + public AspRequiredAttributeAttribute([NotNull] string attribute) + { + Attribute = attribute; + } + + [NotNull] public string Attribute { get; } + } + + [AttributeUsage(AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspTypePropertyAttribute : Attribute + { + public bool CreateConstructorReferences { get; } + + public AspTypePropertyAttribute(bool createConstructorReferences) + { + CreateConstructorReferences = createConstructorReferences; + } + } + + #endregion + + #region ASP.NET MVC + + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcAreaMasterLocationFormatAttribute : Attribute + { + public AspMvcAreaMasterLocationFormatAttribute([NotNull] string format) + { + Format = format; + } + + [NotNull] public string Format { get; } + } + + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcAreaPartialViewLocationFormatAttribute : Attribute + { + public AspMvcAreaPartialViewLocationFormatAttribute([NotNull] string format) + { + Format = format; + } + + [NotNull] public string Format { get; } + } + + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcAreaViewComponentViewLocationFormatAttribute : Attribute + { + public AspMvcAreaViewComponentViewLocationFormatAttribute([NotNull] string format) + { + Format = format; + } + + [NotNull] public string Format { get; } + } + + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcAreaViewLocationFormatAttribute : Attribute + { + public AspMvcAreaViewLocationFormatAttribute([NotNull] string format) + { + Format = format; + } + + [NotNull] public string Format { get; } + } + + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcMasterLocationFormatAttribute : Attribute + { + public AspMvcMasterLocationFormatAttribute([NotNull] string format) + { + Format = format; + } + + [NotNull] public string Format { get; } + } + + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcPartialViewLocationFormatAttribute : Attribute + { + public AspMvcPartialViewLocationFormatAttribute([NotNull] string format) + { + Format = format; + } + + [NotNull] public string Format { get; } + } + + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcViewComponentViewLocationFormatAttribute : Attribute + { + public AspMvcViewComponentViewLocationFormatAttribute([NotNull] string format) + { + Format = format; + } + + [NotNull] public string Format { get; } + } + + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Field | AttributeTargets.Property, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcViewLocationFormatAttribute : Attribute + { + public AspMvcViewLocationFormatAttribute([NotNull] string format) + { + Format = format; + } + + [NotNull] public string Format { get; } + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter + /// is an MVC action. If applied to a method, the MVC action name is calculated + /// implicitly from the context. Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.ChildActionExtensions.RenderAction(HtmlHelper, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | + AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcActionAttribute : Attribute + { + public AspMvcActionAttribute() + { + } + + public AspMvcActionAttribute([NotNull] string anonymousProperty) + { + AnonymousProperty = anonymousProperty; + } + + [CanBeNull] public string AnonymousProperty { get; } + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC area. + /// Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.ChildActionExtensions.RenderAction(HtmlHelper, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcAreaAttribute : Attribute + { + public AspMvcAreaAttribute() + { + } + + public AspMvcAreaAttribute([NotNull] string anonymousProperty) + { + AnonymousProperty = anonymousProperty; + } + + [CanBeNull] public string AnonymousProperty { get; } + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter is + /// an MVC controller. If applied to a method, the MVC controller name is calculated + /// implicitly from the context. Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.ChildActionExtensions.RenderAction(HtmlHelper, String, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | + AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcControllerAttribute : Attribute + { + public AspMvcControllerAttribute() + { + } + + public AspMvcControllerAttribute([NotNull] string anonymousProperty) + { + AnonymousProperty = anonymousProperty; + } + + [CanBeNull] public string AnonymousProperty { get; } + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC Master. Use this attribute + /// for custom wrappers similar to System.Web.Mvc.Controller.View(String, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcMasterAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC model type. Use this attribute + /// for custom wrappers similar to System.Web.Mvc.Controller.View(String, Object). + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcModelTypeAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter is an MVC + /// partial view. If applied to a method, the MVC partial view name is calculated implicitly + /// from the context. Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.RenderPartialExtensions.RenderPartial(HtmlHelper, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | + AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcPartialViewAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Allows disabling inspections for MVC views within a class or a method. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcSuppressViewErrorAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Indicates that a parameter is an MVC display template. + /// Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.DisplayExtensions.DisplayForModel(HtmlHelper, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcDisplayTemplateAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC editor template. + /// Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Html.EditorExtensions.EditorForModel(HtmlHelper, String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcEditorTemplateAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. Indicates that the marked parameter is an MVC template. + /// Use this attribute for custom wrappers similar to + /// System.ComponentModel.DataAnnotations.UIHintAttribute(System.String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcTemplateAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter + /// is an MVC view component. If applied to a method, the MVC view name is calculated implicitly + /// from the context. Use this attribute for custom wrappers similar to + /// System.Web.Mvc.Controller.View(Object). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | + AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcViewAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter + /// is an MVC view component name. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcViewComponentAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. If applied to a parameter, indicates that the parameter + /// is an MVC view component view. If applied to a method, the MVC view component view name is default. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Field | + AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcViewComponentViewAttribute : Attribute + { + } + + /// + /// ASP.NET MVC attribute. When applied to a parameter of an attribute, + /// indicates that this parameter is an MVC action name. + /// + /// + /// [ActionName("Foo")] + /// public ActionResult Login(string returnUrl) { + /// ViewBag.ReturnUrl = Url.Action("Foo"); // OK + /// return RedirectToAction("Bar"); // Error: Cannot resolve action + /// } + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMvcActionSelectorAttribute : Attribute + { + } + + #endregion + + #region ASP.NET Routing + + /// + /// Indicates that the marked parameter, field, or property is a route template. + /// + /// + /// This attribute allows IDE to recognize the use of web frameworks' route templates + /// to enable syntax highlighting, code completion, navigation, rename and other features in string literals. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RouteTemplateAttribute : Attribute + { + } + + /// + /// Indicates that the marked type is custom route parameter constraint, + /// which is registered in the application's Startup with the name ConstraintName. + /// + /// + /// You can specify ProposedType if target constraint matches only route parameters of specific type, + /// it will allow IDE to create method's parameter from usage in route template + /// with specified type instead of default System.String + /// and check if constraint's proposed type conflicts with matched parameter's type. + /// + [AttributeUsage(AttributeTargets.Class)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RouteParameterConstraintAttribute : Attribute + { + [NotNull] public string ConstraintName { get; } + [CanBeNull] public Type ProposedType { get; set; } + + public RouteParameterConstraintAttribute([NotNull] string constraintName) + { + ConstraintName = constraintName; + } + } + + /// + /// Indicates that the marked parameter, field, or property is an URI string. + /// + /// + /// This attribute enables code completion, navigation, renaming and other features + /// in URI string literals assigned to annotated parameters, fields, or properties. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class UriStringAttribute : Attribute + { + public UriStringAttribute() + { + } + + public UriStringAttribute(string httpVerb) + { + HttpVerb = httpVerb; + } + + [CanBeNull] public string HttpVerb { get; } + } + + /// + /// Indicates that the marked method declares routing convention for ASP.NET. + /// + /// + /// The IDE will analyze all usages of methods marked with this attribute, + /// and will add all routes to completion, navigation, and other features over URI strings. + /// + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspRouteConventionAttribute : Attribute + { + public AspRouteConventionAttribute() + { + } + + public AspRouteConventionAttribute(string predefinedPattern) + { + PredefinedPattern = predefinedPattern; + } + + [CanBeNull] public string PredefinedPattern { get; } + } + + /// + /// Indicates that the marked method parameter contains default route values of routing convention for ASP.NET. + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspDefaultRouteValuesAttribute : Attribute + { + } + + /// + /// Indicates that the marked method parameter contains constraints on route values of routing convention for ASP.NET. + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspRouteValuesConstraintsAttribute : Attribute + { + } + + /// + /// Indicates that the marked parameter or property contains routing order provided by ASP.NET routing attribute. + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspRouteOrderAttribute : Attribute + { + } + + /// + /// Indicates that the marked parameter or property contains HTTP verbs provided by ASP.NET routing attribute. + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspRouteVerbsAttribute : Attribute + { + } + + /// + /// Indicates that the marked attribute is used for attribute routing in ASP.NET. + /// + /// + /// The IDE will analyze all usages of attributes marked with this attribute, + /// and will add all routes to completion, navigation and other features over URI strings. + /// + [AttributeUsage(AttributeTargets.Class)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspAttributeRoutingAttribute : Attribute + { + public string HttpVerb { get; set; } + } + + /// + /// Indicates that the marked method declares an ASP.NET Minimal API endpoint. + /// + /// + /// The IDE will analyze all usages of methods marked with this attribute, + /// and will add all routes to completion, navigation and other features over URI strings. + /// + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMinimalApiDeclarationAttribute : Attribute + { + public string HttpVerb { get; set; } + } + + /// + /// Indicates that the marked method declares an ASP.NET Minimal API endpoints group. + /// + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMinimalApiGroupAttribute : Attribute + { + } + + /// + /// Indicates that the marked parameter contains an ASP.NET Minimal API endpoint handler. + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMinimalApiHandlerAttribute : Attribute + { + } + + /// + /// Indicates that the marked method contains Minimal API endpoint declaration. + /// + /// + /// The IDE will analyze all usages of methods marked with this attribute, + /// and will add all declared in attributes routes to completion, navigation and other features over URI strings. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class AspMinimalApiImplicitEndpointDeclarationAttribute : Attribute + { + public string HttpVerb { get; set; } + + public string RouteTemplate { get; set; } + + public Type BodyType { get; set; } + + /// + /// Comma-separated list of query parameters defined for endpoint + /// + public string QueryParameters { get; set; } + } + + #endregion + + #region Razor + + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class HtmlElementAttributesAttribute : Attribute + { + public HtmlElementAttributesAttribute() + { + } + + public HtmlElementAttributesAttribute([NotNull] string name) + { + Name = name; + } + + [CanBeNull] public string Name { get; } + } + + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class HtmlAttributeValueAttribute : Attribute + { + public HtmlAttributeValueAttribute([NotNull] string name) + { + Name = name; + } + + [NotNull] public string Name { get; } + } + + /// + /// Razor attribute. Indicates that the marked parameter or method is a Razor section. + /// Use this attribute for custom wrappers similar to + /// System.Web.WebPages.WebPageBase.RenderSection(String). + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorSectionAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorImportNamespaceAttribute : Attribute + { + public RazorImportNamespaceAttribute([NotNull] string name) + { + Name = name; + } + + [NotNull] public string Name { get; } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorInjectionAttribute : Attribute + { + public RazorInjectionAttribute([NotNull] string type, [NotNull] string fieldName) + { + Type = type; + FieldName = fieldName; + } + + [NotNull] public string Type { get; } + + [NotNull] public string FieldName { get; } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorDirectiveAttribute : Attribute + { + public RazorDirectiveAttribute([NotNull] string directive) + { + Directive = directive; + } + + [NotNull] public string Directive { get; } + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorPageBaseTypeAttribute : Attribute + { + public RazorPageBaseTypeAttribute([NotNull] string baseType) + { + BaseType = baseType; + } + + public RazorPageBaseTypeAttribute([NotNull] string baseType, string pageName) + { + BaseType = baseType; + PageName = pageName; + } + + [NotNull] public string BaseType { get; } + [CanBeNull] public string PageName { get; } + } + + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorHelperCommonAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorLayoutAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorWriteLiteralMethodAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Method)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorWriteMethodAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Parameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class RazorWriteMethodParameterAttribute : Attribute + { + } + + #endregion + + #region XAML + + /// + /// XAML attribute. Indicates the type that has an ItemsSource property and should be treated + /// as an ItemsControl-derived type, to enable inner items DataContext type resolution. + /// + [AttributeUsage(AttributeTargets.Class)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class XamlItemsControlAttribute : Attribute + { + } + + /// + /// XAML attribute. Indicates the property of some BindingBase-derived type, that + /// is used to bind some item of an ItemsControl-derived type. This annotation will + /// enable the DataContext type resolve for XAML bindings for such properties. + /// + /// + /// The property should have the tree ancestor of the ItemsControl type, or + /// marked with the attribute. + /// + [AttributeUsage(AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class XamlItemBindingOfItemsControlAttribute : Attribute + { + } + + /// + /// XAML attribute. Indicates the property of some Style-derived type that + /// is used to style items of an ItemsControl-derived type. This annotation will + /// enable the DataContext type resolve for XAML bindings for such properties. + /// + /// + /// Property should have the tree ancestor of the ItemsControl type or + /// marked with the attribute. + /// + [AttributeUsage(AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class XamlItemStyleOfItemsControlAttribute : Attribute + { + } + + /// + /// XAML attribute. Indicates that DependencyProperty has OneWay binding mode by default. + /// + /// + /// This attribute must be applied to DependencyProperty's CLR accessor property if it is present, or to a DependencyProperty descriptor field otherwise. + /// + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class XamlOneWayBindingModeByDefaultAttribute : Attribute + { + } + + /// + /// XAML attribute. Indicates that DependencyProperty has TwoWay binding mode by default. + /// + /// + /// This attribute must be applied to DependencyProperty's CLR accessor property if it is present, or to a DependencyProperty descriptor field otherwise. + /// + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class XamlTwoWayBindingModeByDefaultAttribute : Attribute + { + } + + #endregion + + #region Unit Testing + + /// + /// Specifies the subject being tested by a test class or a test method. + /// + /// + /// The can be applied to a test class or a test method to indicate what class + /// or interface the tests defined in them test. This information can be used by an IDE to provide better navigation + /// support or by test runners to group tests by subject and to provide better test reports. + /// + [AttributeUsage( + AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Class | AttributeTargets.Interface, + AllowMultiple = true)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class TestSubjectAttribute : Attribute + { + /// + /// Gets the type of the subject being tested. + /// + [NotNull] + public Type Subject { get; } + + /// + /// Initializes a new instance of the class with the specified subject type. + /// + /// The type of the subject being tested. + public TestSubjectAttribute([NotNull] Type subject) + { + Subject = subject; + } + } + + /// + /// Signifies a generic argument as the test subject for a test class. + /// + /// + /// The can be applied to a generic parameter of a base test class to indicate that + /// the type passed as the argument is the class being tested. This information can be used by an IDE to provide better + /// navigation support or by test runners to group tests by subject and to provide better test reports. + /// + /// + /// public class BaseTestClass<[MeansTestSubject] T> + /// { + /// protected T Component { get; } + /// } + /// + /// public class CalculatorAdditionTests : BaseTestClass<Calculator> + /// { + /// [Test] + /// public void Should_add_two_numbers() + /// { + /// Assert.That(Component.Add(2, 3), Is.EqualTo(5)); + /// } + /// } + /// + [AttributeUsage(AttributeTargets.GenericParameter)] + [Conditional("JETBRAINS_ANNOTATIONS")] + internal sealed class MeansTestSubjectAttribute : Attribute + { + } + + #endregion +} \ No newline at end of file diff --git a/RQ-Core/JetBrainsAnnotations/JetBrainsAnnotations.cs.meta b/RQ-Core/JetBrainsAnnotations/JetBrainsAnnotations.cs.meta new file mode 100644 index 00000000..6be89dd6 --- /dev/null +++ b/RQ-Core/JetBrainsAnnotations/JetBrainsAnnotations.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a4cd97bf127e4089b87d103aea619621 +timeCreated: 1717367862 \ No newline at end of file diff --git a/RQ-Core/JetBrainsAnnotations/assembly-info.cs b/RQ-Core/JetBrainsAnnotations/assembly-info.cs new file mode 100644 index 00000000..8959d0a6 --- /dev/null +++ b/RQ-Core/JetBrainsAnnotations/assembly-info.cs @@ -0,0 +1,7 @@ +#region + +using System.Runtime.CompilerServices; + +#endregion + +[assembly: InternalsVisibleTo("nadena.dev.ndmf.reactive-query.core")] \ No newline at end of file diff --git a/RQ-Core/JetBrainsAnnotations/assembly-info.cs.meta b/RQ-Core/JetBrainsAnnotations/assembly-info.cs.meta new file mode 100644 index 00000000..5995c800 --- /dev/null +++ b/RQ-Core/JetBrainsAnnotations/assembly-info.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0254cf982db5436295033e3fc5a3b7dd +timeCreated: 1717367862 \ No newline at end of file diff --git a/RQ-Core/JetBrainsAnnotations/nadena.dev.ndmf.reactive-query.jetbrains-annotations.asmdef b/RQ-Core/JetBrainsAnnotations/nadena.dev.ndmf.reactive-query.jetbrains-annotations.asmdef new file mode 100644 index 00000000..76f4498a --- /dev/null +++ b/RQ-Core/JetBrainsAnnotations/nadena.dev.ndmf.reactive-query.jetbrains-annotations.asmdef @@ -0,0 +1,14 @@ +{ + "name": "nadena.dev.ndmf.reactive-query.jetbrains-annotations", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/RQ-Core/JetBrainsAnnotations/nadena.dev.ndmf.reactive-query.jetbrains-annotations.asmdef.meta b/RQ-Core/JetBrainsAnnotations/nadena.dev.ndmf.reactive-query.jetbrains-annotations.asmdef.meta new file mode 100644 index 00000000..5610683a --- /dev/null +++ b/RQ-Core/JetBrainsAnnotations/nadena.dev.ndmf.reactive-query.jetbrains-annotations.asmdef.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 139f0abb049c44f18b29dd824ebf1f20 +timeCreated: 1717367862 \ No newline at end of file diff --git a/RQ-Core/ObjectIdentityComparer.cs b/RQ-Core/ObjectIdentityComparer.cs new file mode 100644 index 00000000..702b9f0e --- /dev/null +++ b/RQ-Core/ObjectIdentityComparer.cs @@ -0,0 +1,25 @@ +#region + +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + internal class ObjectIdentityComparer : IEqualityComparer + { + public static ObjectIdentityComparer Instance { get; } = new ObjectIdentityComparer(); + + + public bool Equals(T x, T y) + { + return ReferenceEquals(x, y); + } + + public int GetHashCode(T obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } +} \ No newline at end of file diff --git a/RQ-Core/ObjectIdentityComparer.cs.meta b/RQ-Core/ObjectIdentityComparer.cs.meta new file mode 100644 index 00000000..241edfc3 --- /dev/null +++ b/RQ-Core/ObjectIdentityComparer.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: bff596dec3994e08868aa0834f0cabfc +timeCreated: 1714257299 \ No newline at end of file diff --git a/RQ-Core/ReactiveField.cs b/RQ-Core/ReactiveField.cs new file mode 100644 index 00000000..e9aa9d66 --- /dev/null +++ b/RQ-Core/ReactiveField.cs @@ -0,0 +1,36 @@ +#region + +using System.Threading.Tasks; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + public sealed class ReactiveField + { + private T _value; + + public T Value + { + get => _value; + set + { + _value = value; + _reactiveValue.Invalidate(); + } + } + + private ReactiveValue _reactiveValue; + + public ReactiveValue AsReactiveValue() + { + return _reactiveValue; + } + + public ReactiveField(T value) + { + _value = value; + _reactiveValue = ReactiveValue.Create("reactive field", _ => Task.FromResult(_value)); + } + } +} \ No newline at end of file diff --git a/RQ-Core/ReactiveField.cs.meta b/RQ-Core/ReactiveField.cs.meta new file mode 100644 index 00000000..5a49885f --- /dev/null +++ b/RQ-Core/ReactiveField.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7063bf7e2e2b4a4aa7ebd50cdc4b0706 +timeCreated: 1716151280 \ No newline at end of file diff --git a/RQ-Core/ReactiveQuery.cs b/RQ-Core/ReactiveQuery.cs new file mode 100644 index 00000000..0633f60c --- /dev/null +++ b/RQ-Core/ReactiveQuery.cs @@ -0,0 +1,134 @@ +#region + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + /// + /// Represents a mapping from T to ReactiveValue, which is computed and updated asynchronously. + /// + /// + /// + public sealed class ReactiveQuery where T : class + { + private readonly object _lock = new object(); + private readonly string _description; + private readonly Func> _compute; + private readonly Dictionary, WeakReference>> _cache = new(); + + private int additions = 0; + + public ReactiveQuery(string description, Func> compute) + { + _compute = compute; + _description = description; + } + + public ReactiveValue Get(T key) + { + lock (_lock) + { + if (_cache.TryGetValue(new WeakKey(key), out var weakQuery)) + { + if (weakQuery.TryGetTarget(out var query)) + { + return query; + } + } + + var newQuery = new CacheValue(key, _compute, _description); + _cache[new WeakKey(key)] = new WeakReference>(newQuery); + additions++; + + if (additions > _cache.Count / 2) + { + PruneCache(); + } + + return newQuery; + } + } + + private void PruneCache() + { + lock (_lock) + { + additions = 0; + + var keysToRemove = new List>(); + foreach (var pair in _cache) + { + if (!pair.Value.TryGetTarget(out _)) + { + keysToRemove.Add(pair.Key); + } + } + + foreach (var key in keysToRemove) + { + _cache.Remove(key); + } + } + } + + private class WeakKey where T : class + { + private readonly WeakReference _key; + private int _hashCode; + + public WeakKey(T key) + { + _key = new WeakReference(key); + _hashCode = key.GetHashCode(); + } + + public override int GetHashCode() + { + return _hashCode; + } + + public override bool Equals(object obj) + { + if (this == obj) return true; + + if (obj is WeakKey other) + { + if (_key.TryGetTarget(out var key) && other._key.TryGetTarget(out var otherKey)) + { + return key.Equals(otherKey); + } + } + + return false; + } + } + + private class CacheValue : ReactiveValue + { + private readonly string _description; + private readonly T _key; + private readonly Func> _compute; + + public CacheValue(T key, Func> compute, string description) + { + _key = key; + _compute = compute; + _description = description + " for " + key; + } + + protected override Task Compute(ComputeContext context) + { + return _compute(context, _key); + } + + public override string ToString() + { + return _description; + } + } + } +} \ No newline at end of file diff --git a/RQ-Core/ReactiveQuery.cs.meta b/RQ-Core/ReactiveQuery.cs.meta new file mode 100644 index 00000000..6b8ada11 --- /dev/null +++ b/RQ-Core/ReactiveQuery.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 92fcabc7f9824770bae2d4d16ef5f454 +timeCreated: 1716158294 \ No newline at end of file diff --git a/RQ-Core/ReactiveQueryScheduler.cs b/RQ-Core/ReactiveQueryScheduler.cs new file mode 100644 index 00000000..44b9431b --- /dev/null +++ b/RQ-Core/ReactiveQueryScheduler.cs @@ -0,0 +1,38 @@ +#region + +using System.Threading; +using System.Threading.Tasks; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + public static class ReactiveQueryScheduler + { + public static TaskFactory DefaultTaskFactory { get; set; } = new TaskFactory(TaskScheduler.Default); + + public static ThreadLocal SynchronizationContextOverride { get; } = new(() => null); + + public static SynchronizationContext SynchronizationContext => + SynchronizationContextOverride.Value ?? SynchronizationContext.Current; + + + public static TaskScheduler TaskScheduler + { + get + { + var oldContext = SynchronizationContext.Current; + if (SynchronizationContextOverride.Value != null) + { + SynchronizationContext.SetSynchronizationContext(SynchronizationContextOverride.Value); + } + + var scheduler = TaskScheduler.FromCurrentSynchronizationContext(); + + SynchronizationContext.SetSynchronizationContext(oldContext); + + return scheduler; + } + } + } +} \ No newline at end of file diff --git a/RQ-Core/ReactiveQueryScheduler.cs.meta b/RQ-Core/ReactiveQueryScheduler.cs.meta new file mode 100644 index 00000000..1234bb1b --- /dev/null +++ b/RQ-Core/ReactiveQueryScheduler.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 69391bc71b4c469fad11dfa8e29eb58f +timeCreated: 1714266067 \ No newline at end of file diff --git a/RQ-Core/ReactiveValue.cs b/RQ-Core/ReactiveValue.cs new file mode 100644 index 00000000..8ed8f2d4 --- /dev/null +++ b/RQ-Core/ReactiveValue.cs @@ -0,0 +1,486 @@ +#region + +using System; +using System.Collections.Generic; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + /// + /// A ReactiveQuery represents a cached computation, which can be automatically invalidated and recomputed when + /// something it relied upon changes. + /// + /// ## Obtaining values from a ReactiveQuery + /// + /// There are four ways to obtain a value from a ReactiveQuery: + /// 1. Continually, by subscribing to the query using `Subscribe`. This option will trigger the computation of the + /// value if it is not already available, and will also trigger the computation of the value whenever the query is + /// invalidated. + /// 2. Speculatively, using `TryGetValue` (which will return `false` if the value is not yet available). This option + /// does not trigger the computation of the value in most cases. + /// 2. Asynchronously, using `GetValueAsync` (which will return a `Task` that will complete when the value is + /// available). This option will trigger the computation of the value if it is not already available. + /// 4. From within another ReactiveQuery, by using `ComputeContext.Observe`. This will record the dependency on the + /// other query, and will trigger the computation of the value if it is not already available. It will then + /// arrange for the calling query to be re-computed whenever the observed query is invalidated. + /// + /// ## Writing a ReactiveQuery + /// + /// To write a ReactiveQuery, you must implement the `Compute` and `ToString` methods. `Compute` will be invoked + /// when the query is invalidated, and should return the new value of the computation. Note that, if something + /// is invalidated while the query is updating, the computation will be cancelled (throwing `TaskCancelledException`s + /// when observing sub-queries) and restarted. + /// + /// Generally, it is important to cache the ReactiveQuery itself somehow (you won't get a lot of benefit from caching + /// if you create a new ReactiveQuery every time!) + /// + /// ## Threading notes + /// + /// ReactiveQuery will avoid invoking Compute more than once in parallel; even if invalidated, the last execution + /// will run to completion (and its result will be ignored), then the new computation will be started. + /// + /// Likewise, when using the IObservable interface to observe the state of a query, ReactiveQuery will not invoke + /// methods on any particular observer in parallel. It is not guaranteed that observers will see every computed + /// value; we only guarantee that eventually, if invalidations stop, all observers will see the last value. + /// + /// ## Using ReactiveQuery in Unity + /// + /// When using RQ in Unity, generally you'll want to use the `Subscribe` method to drive various unity editor UI + /// bits. If you need to access a ReactiveQuery _synchronously_ (for example, in a NDMF plugin), refer to + /// `ReactiveQueryUnityExt.GetSync`. + /// + /// The default TaskFactory for RQ on Unity projects will be one that runs on the Unity main thread. You can + /// override this by overriding the `Scheduler` property with an appropriate `TaskFactory`. + /// + /// + /// + public abstract class ReactiveValue : IObservable + { + [PublicAPI] protected virtual TaskScheduler TaskScheduler { get; } = ReactiveQueryScheduler.TaskScheduler; + + #region State + + private object _lock = new(); + + // Locked by _lock + private long _invalidationCount = 0; + + private CancellationToken _cancellationToken = CancellationToken.None; + private Task _cancelledTask = null; + private TaskCompletionSource _invalidated = null; + + private bool _currentValueIsValid = false; + + private Task _valueTask = null; + + // Used to drive DestroyObsoleteValue + private T _currentValue = default; + private Exception _currentValueException = null; + + #endregion + + #region Public API + + private class SimpleValue : ReactiveValue + { + private string _description; + private Func> _compute; + + public SimpleValue(string description, Func> compute) + { + _description = description; + _compute = compute; + } + + protected override Task Compute(ComputeContext context) + { + return _compute(context); + } + + public override string ToString() + { + return _description; + } + } + + /// + /// Creates a ReactiveQuery based on a computation delegate. + /// + /// The description of the query, used in error messages + /// The function to invoke to compute the query + /// + /// + public static ReactiveValue Create(string description, Func> compute) + { + return new SimpleValue(description, compute); + } + + public ReactiveValue Map(Func map) + { + return ReactiveValue.Create(ToString(), + async context => { return map(await context.Observe(this)); }); + } + + /// + /// Attempts to get the current value, but only if it is available immediately. + /// + /// If the value is unavailable immediately: + /// * If a value has been computed previously, a stale value will be placed in `value`, and the function will + /// return false. + /// * If no value has been computed yet, `value` will contain `default`. + /// * In either case, an asynchronous computation will be initiated. + /// + /// The value, if available + /// True if the value was available, false if not + /// If the last computation failed + public bool TryGetValue(out T value) + { + lock (_lock) + { + value = _currentValue; + if (_currentValueException != null) + { + throw _currentValueException; + } + + if (!_currentValueIsValid) + { + RequestCompute(); + return false; + } + + return true; + } + } + + /// + /// Returns a Task which will resolve to the query's latest value. + /// + /// + public async Task GetValueAsync() + { + while (true) + { + try + { + return await RequestCompute(); + } + catch (TaskCanceledException e) + { + continue; + } + } + } + + /// + /// Returns a task which will complete the next time this task is invalidated. + /// + public Task Invalidated + { + get + { + lock (_lock) + { + if (_invalidated == null) + { + _invalidated = new TaskCompletionSource(); + } + + return _invalidated.Task; + } + } + } + + #endregion + + #region IObservable API + + private class ObserverContext + { + private readonly TaskScheduler _scheduler; + private IObserver _observer; + private Task _priorInvocation = Task.CompletedTask; + + public ObserverContext(IObserver observer, TaskScheduler scheduler) + { + _observer = observer; + _scheduler = scheduler; + } + + public void Invoke(Action> action) + { + _priorInvocation = _priorInvocation.ContinueWith(_ => action(_observer), + CancellationToken.None, + // Ensure that we don't invoke an observation while holding our lock + TaskContinuationOptions.RunContinuationsAsynchronously, + _scheduler + ); + } + } + + private HashSet> _observers = new(new ObjectIdentityComparer>()); + + /// + /// Subscribes an observer to this query. The observer will be executed on the TaskScheduler associated with the + /// current synchronization context. + /// + /// + /// A disposable which will deregister the observer, once disposed + public IDisposable Subscribe(IObserver observer) + { + return Subscribe(observer, null); + } + + /// + /// Subscribes an observer to this query. The observer will be executed on the provided TaskScheduler. + /// If the provided scheduler is null, the observer will be executed on the TaskScheduler associated with the + /// current synchronization context. + /// + /// + /// + /// + [PublicAPI] + public IDisposable Subscribe(IObserver observer, TaskScheduler scheduler) + { + scheduler = scheduler ?? TaskScheduler.FromCurrentSynchronizationContext(); + + var observerContext = new ObserverContext(observer, scheduler); + + lock (_lock) + { + _observers.Add(observerContext); + + if (_currentValueIsValid) + { + var cv = _currentValue; + var ex = _currentValueException; + + observerContext.Invoke(o => + { + if (ex != null) + { + o.OnError(ex); + } + else + { + o.OnNext(cv); + } + }); + } + else + { + RequestCompute(); + } + } + + return new RemoveObserver(this, observerContext); + } + + private class RemoveObserver : IDisposable + { + private readonly ReactiveValue _parent; + private readonly ObserverContext _observer; + + public RemoveObserver(ReactiveValue parent, ObserverContext observer) + { + _parent = parent; + _observer = observer; + } + + public void Dispose() + { + lock (_parent._lock) + { + _parent._observers.Remove(_observer); + _observer.Invoke(o => o.OnCompleted()); + } + } + } + + /// + /// Immediately invalidates the query. If there are downstream computations or observers, the query will be + /// recomputed. + /// + public void Invalidate() + { + Invalidate(-1); + } + + #endregion + + #region Subclass API + + /// + /// Invoked when the query needs to be recomputed. This method should return the new value of the computation. + /// + /// + /// + protected abstract Task Compute(ComputeContext context); + + /// + /// Invoked when the query is invalidated and the current value is no longer needed. This method should clean up + /// any resources associated with the passed value. + /// + /// + protected virtual void DestroyObsoleteValue(T value) + { + // no-op + } + + // Implementing ToString is mandatory for all subclasses + public abstract override string ToString(); + + #endregion + + #region Internal API + + internal void Invalidate(long expectedSeq) + { + using (new SyncContextScope(ReactiveQueryScheduler.SynchronizationContext)) + { + TaskCompletionSource invalidationToken = null; + + lock (_lock) + { + if (expectedSeq == _invalidationCount || expectedSeq == -1) + { + if (_valueTask != null && !_valueTask.IsCompleted) + { + _cancelledTask = _valueTask; + } + + invalidationToken = _invalidated; + _invalidated = null; + _invalidationCount++; + _valueTask = null; + + _currentValueIsValid = false; + } + + if (_observers.Count > 0) + { + RequestCompute(); + + foreach (var observer in _observers) + { + observer.Invoke(o => (o as IInvalidationObserver)?.OnInvalidate()); + } + } + } + + // This triggers invalidation of downstream queries (as well as potentially other user code), so drop the + // lock before invoking it... + invalidationToken?.SetResult(null); + } + } + + internal async Task ComputeInternal(ComputeContext context) + { + await TaskThrottle.MaybeThrottle(); + + long seq = _invalidationCount; + + Task cancelledTask; + lock (_lock) + { + cancelledTask = _cancelledTask; + _cancelledTask = null; + + context.OnInvalidate = Invalidated; + } + + // Ensure we don't ever have multiple instances of the same RQ computation running in parallel + if (cancelledTask != null) + { + await cancelledTask.ContinueWith(_ => { }); // swallow exceptions + } + + T result; + ExceptionDispatchInfo e; + try + { + result = await Compute(context); + e = null; + } + catch (Exception ex) + { + result = default; + e = ExceptionDispatchInfo.Capture(ex); + } + + Console.WriteLine("ComputeInternal: before lock"); + lock (_lock) + { + if (_invalidationCount == seq) + { + if (e == null && !ReferenceEquals(result, _currentValue)) + { + DestroyObsoleteValue(_currentValue); + } + + _currentValue = result; + _currentValueException = e?.SourceException; + _currentValueIsValid = true; + + Action> op = observer => + { + if (e != null) + { + observer.OnError(e.SourceException); + } + else + { + observer.OnNext(result); + } + }; + + Console.WriteLine("ComputeInternal: before observers"); + foreach (var observer in _observers) + { + observer.Invoke(op); + } + } + } + + Console.WriteLine("ComputeInternal: before exit"); + e?.Throw(); + return result; + } + + internal Task RequestCompute() + { + lock (_lock) + { + if (_valueTask == null) + { + var context = new ComputeContext(() => ToString()); + + var invalidateSeq = _invalidationCount; + context.Invalidate = () => Invalidate(invalidateSeq); + // TODO: arrange for cancellation when we invalidate the task + context.CancellationToken = new CancellationToken(); + + using (new SyncContextScope(ReactiveQueryScheduler.SynchronizationContext)) + { + // _context.Activate(); + _valueTask = Task.Factory.StartNew( + () => ComputeInternal(context), + context.CancellationToken, + TaskCreationOptions.None, + TaskScheduler + ).Unwrap(); + } + } + + return _valueTask; + } + } + + #endregion + } +} \ No newline at end of file diff --git a/RQ-Core/ReactiveValue.cs.meta b/RQ-Core/ReactiveValue.cs.meta new file mode 100644 index 00000000..4d3e5781 --- /dev/null +++ b/RQ-Core/ReactiveValue.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1f79bd7aba4a49b88bec8f2fbef092da +timeCreated: 1713143151 \ No newline at end of file diff --git a/RQ-Core/SyncContextScope.cs b/RQ-Core/SyncContextScope.cs new file mode 100644 index 00000000..b297c1e9 --- /dev/null +++ b/RQ-Core/SyncContextScope.cs @@ -0,0 +1,24 @@ +#region + +using System; +using System.Threading; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + public sealed class SyncContextScope : IDisposable + { + SynchronizationContext _old = SynchronizationContext.Current; + + public SyncContextScope(SynchronizationContext context) + { + SynchronizationContext.SetSynchronizationContext(context); + } + + public void Dispose() + { + SynchronizationContext.SetSynchronizationContext(_old); + } + } +} \ No newline at end of file diff --git a/RQ-Core/SyncContextScope.cs.meta b/RQ-Core/SyncContextScope.cs.meta new file mode 100644 index 00000000..a0457f21 --- /dev/null +++ b/RQ-Core/SyncContextScope.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f19971b82c8c4b2d9ae62cddd183992f +timeCreated: 1714617144 \ No newline at end of file diff --git a/RQ-Core/SynchronousTaskScheduler.cs b/RQ-Core/SynchronousTaskScheduler.cs new file mode 100644 index 00000000..9ef4c80c --- /dev/null +++ b/RQ-Core/SynchronousTaskScheduler.cs @@ -0,0 +1,31 @@ +#region + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + internal sealed class SynchronousTaskScheduler : TaskScheduler + { + internal static SynchronousTaskScheduler Instance { get; } = new SynchronousTaskScheduler(); + + protected override IEnumerable GetScheduledTasks() + { + return Array.Empty(); + } + + protected override void QueueTask(Task task) + { + TryExecuteTask(task); + } + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) + { + TryExecuteTask(task); + return true; + } + } +} \ No newline at end of file diff --git a/RQ-Core/SynchronousTaskScheduler.cs.meta b/RQ-Core/SynchronousTaskScheduler.cs.meta new file mode 100644 index 00000000..44de798e --- /dev/null +++ b/RQ-Core/SynchronousTaskScheduler.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 00913519e61941999795b6ff98706689 +timeCreated: 1714260387 \ No newline at end of file diff --git a/RQ-Core/TaskExt.cs b/RQ-Core/TaskExt.cs new file mode 100644 index 00000000..8cb2c9d8 --- /dev/null +++ b/RQ-Core/TaskExt.cs @@ -0,0 +1,50 @@ +#region + +using System.Threading; +using System.Threading.Tasks; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + public static class TaskExt + { + /// + /// Prevents deep recursion by ensuring that this task, upon completion, returns to the thread pool instead of + /// immediately calling its continuation. This should be used carefully, as it can negatively impact performance. + /// + /// This differs from simply using TaskContinuationOptions.RunContinuationsAsynchronously in that it will + /// ensure that the correct synchronization context is used for the continuation. + /// + /// + /// + public static Task PreventRecursion(this Task t) + { + return t.ContinueWith( + t2 => t2, + CancellationToken.None, + TaskContinuationOptions.RunContinuationsAsynchronously, + TaskScheduler.FromCurrentSynchronizationContext() + ); + } + + /// + /// Prevents deep recursion by ensuring that this task, upon completion, returns to the thread pool instead of + /// immediately calling its continuation. This should be used carefully, as it can negatively impact performance. + /// + /// This differs from simply using TaskContinuationOptions.RunContinuationsAsynchronously in that it will + /// ensure that the correct synchronization context is used for the continuation. + /// + /// + /// + public static Task PreventRecursion(this Task t) + { + return t.ContinueWith( + t2 => t2.Result, + CancellationToken.None, + TaskContinuationOptions.RunContinuationsAsynchronously, + TaskScheduler.FromCurrentSynchronizationContext() + ); + } + } +} \ No newline at end of file diff --git a/RQ-Core/TaskExt.cs.meta b/RQ-Core/TaskExt.cs.meta new file mode 100644 index 00000000..0192d97e --- /dev/null +++ b/RQ-Core/TaskExt.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4803bf2a36f543059b3922ef6dba5e78 +timeCreated: 1714619078 \ No newline at end of file diff --git a/RQ-Core/TaskThrottle.cs b/RQ-Core/TaskThrottle.cs new file mode 100644 index 00000000..fbdd9c74 --- /dev/null +++ b/RQ-Core/TaskThrottle.cs @@ -0,0 +1,49 @@ +#region + +using System; +using System.Threading; +using System.Threading.Tasks; + +#endregion + +namespace nadena.dev.ndmf.rq +{ + public static class TaskThrottle + { + public static readonly ThreadLocal> ShouldThrottle = new(() => () => false); + + public static async ValueTask MaybeThrottle() + { + if (ShouldThrottle.Value()) + { + await Task.CompletedTask.ContinueWith( + _ => Task.CompletedTask, + CancellationToken.None, + TaskContinuationOptions.RunContinuationsAsynchronously, + TaskScheduler.Current + ); + } + } + + public static IDisposable WithThrottleCondition(Func condition) + { + return new ThrottleConditionScope(condition); + } + + private class ThrottleConditionScope : IDisposable + { + private readonly Func _previousCondition; + + public ThrottleConditionScope(Func condition) + { + _previousCondition = ShouldThrottle.Value; + ShouldThrottle.Value = condition; + } + + public void Dispose() + { + ShouldThrottle.Value = _previousCondition; + } + } + } +} \ No newline at end of file diff --git a/RQ-Core/TaskThrottle.cs.meta b/RQ-Core/TaskThrottle.cs.meta new file mode 100644 index 00000000..5d9d04b7 --- /dev/null +++ b/RQ-Core/TaskThrottle.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e7ea458ccf8b4864a5539cd8eaa0bc74 +timeCreated: 1714354386 \ No newline at end of file diff --git a/RQ-Core/assembly-info.cs b/RQ-Core/assembly-info.cs new file mode 100644 index 00000000..b88f465f --- /dev/null +++ b/RQ-Core/assembly-info.cs @@ -0,0 +1,7 @@ +#region + +using System.Runtime.CompilerServices; + +#endregion + +[assembly: InternalsVisibleTo("nadena.dev.ndmf")] \ No newline at end of file diff --git a/RQ-Core/assembly-info.cs.meta b/RQ-Core/assembly-info.cs.meta new file mode 100644 index 00000000..9e541117 --- /dev/null +++ b/RQ-Core/assembly-info.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ae28ada1f2524bbba57e038ec9a0e1e1 +timeCreated: 1714360247 \ No newline at end of file diff --git a/RQ-Core/nadena.dev.ndmf.reactive-query.core.asmdef b/RQ-Core/nadena.dev.ndmf.reactive-query.core.asmdef new file mode 100644 index 00000000..8ad22006 --- /dev/null +++ b/RQ-Core/nadena.dev.ndmf.reactive-query.core.asmdef @@ -0,0 +1,16 @@ +{ + "name": "nadena.dev.ndmf.reactive-query.core", + "rootNamespace": "", + "references": [ + "nadena.dev.ndmf.reactive-query.jetbrains-annotations" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} \ No newline at end of file diff --git a/RQ-Core/nadena.dev.ndmf.reactive-query.core.asmdef.meta b/RQ-Core/nadena.dev.ndmf.reactive-query.core.asmdef.meta new file mode 100644 index 00000000..c00b8199 --- /dev/null +++ b/RQ-Core/nadena.dev.ndmf.reactive-query.core.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dea0612daadfed648b1e06f1a009ab5e +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/SelfDestructComponent.cs b/Runtime/SelfDestructComponent.cs new file mode 100644 index 00000000..69226799 --- /dev/null +++ b/Runtime/SelfDestructComponent.cs @@ -0,0 +1,29 @@ +#region + +using System; +using UnityEditor; +using UnityEngine; + +#endregion + +/// +/// This component will self destruct one frame after load, unless the KeepAlive field is set to a live object. +/// +[AddComponentMenu("")] +public class SelfDestructComponent : MonoBehaviour +{ + [NonSerialized] public object KeepAlive; // don't destroy when non-null (non-serialized field) + + void OnValidate() + { +#if UNITY_EDITOR + EditorApplication.delayCall += () => + { + if (this != null && KeepAlive == null) + { + DestroyImmediate(gameObject); + } + }; +#endif + } +} \ No newline at end of file diff --git a/Runtime/SelfDestructComponent.cs.meta b/Runtime/SelfDestructComponent.cs.meta new file mode 100644 index 00000000..359a6617 --- /dev/null +++ b/Runtime/SelfDestructComponent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 85007fe4bf2143538d606445019b01a2 +timeCreated: 1717367902 \ No newline at end of file diff --git a/UnitTests~/RQ-EditorTests.meta b/UnitTests~/RQ-EditorTests.meta new file mode 100644 index 00000000..3b47ec19 --- /dev/null +++ b/UnitTests~/RQ-EditorTests.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 25904f74e2894a23916b8081830b7498 +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-EditorTests/RQUnityIntegrationTest.cs b/UnitTests~/RQ-EditorTests/RQUnityIntegrationTest.cs new file mode 100644 index 00000000..d79cc47c --- /dev/null +++ b/UnitTests~/RQ-EditorTests/RQUnityIntegrationTest.cs @@ -0,0 +1,67 @@ +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using nadena.dev.ndmf.rq; +using nadena.dev.ndmf.rq.StandaloneTests; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +namespace UnitTests.EditorTests +{ + public class RQUnityIntegrationTest + { + private static int mainThreadId; + + [InitializeOnLoadMethod] + private static void Initialize() + { + mainThreadId = Thread.CurrentThread.ManagedThreadId; + } + + [Test] + public async Task ReactiveQueriesRunInMainThread() + { + ReactiveValue rq = + ReactiveValue.Create("", _ => Task.FromResult(Thread.CurrentThread.ManagedThreadId)); + + Assert.AreEqual(mainThreadId, await rq.GetValueAsync().Timeout()); + } + + [Test] + public async Task ReactiveQueriesThrottle() + { + int startedCount = 0; + List> queries = new List>(); + + var pq = ReactiveValue.Create("", _ => Task.FromResult(1)); + + for (int i = 0; i < 10; i++) + { + queries.Insert(0, ReactiveValue.Create("", async ctx => + { + Interlocked.Increment(ref startedCount); + //.Debug.Log("sleep start"); + Thread.Sleep(50); + //Debug.Log("sleep end"); + return 1; + }).GetValueAsync().ContinueWith(t => + { + //Debug.Log("Task complete"); + return t.Result; + })); + } + + await Task.Delay(25);//.ContinueWith(_ => Debug.Log("Delay complete")); + + var completedCount = queries.Count(q => q.IsCompleted); + Debug.Log("Completed: " + completedCount); + Assert.IsTrue(completedCount > 0); + Assert.IsTrue(completedCount < queries.Count); + + var allDone = Task.WhenAll(queries); + Assert.AreSame(allDone, await Task.WhenAny(allDone, Task.Delay(2000))); + } + } +} \ No newline at end of file diff --git a/UnitTests~/RQ-EditorTests/RQUnityIntegrationTest.cs.meta b/UnitTests~/RQ-EditorTests/RQUnityIntegrationTest.cs.meta new file mode 100644 index 00000000..b7dba987 --- /dev/null +++ b/UnitTests~/RQ-EditorTests/RQUnityIntegrationTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ecfc1aaa02224c569dfb32e8cd3a1272 +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs b/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs new file mode 100644 index 00000000..84bdc1d6 --- /dev/null +++ b/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs @@ -0,0 +1,461 @@ +using System; +using System.Collections.Generic; +using nadena.dev.ndmf.rq.unity.editor; +using NUnit.Framework; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace UnitTests.EditorTests +{ + public class ShadowHierarchyTest + { + private List createdObjects = new List(); + + private T c(T obj) where T: UnityEngine.Object + { + createdObjects.Add(obj); + return obj; + } + + private GameObject c(string s) + { + return c(new GameObject(s)); + } + + [TearDown] + public void TearDown() + { + foreach (var obj in createdObjects) + { + Object.DestroyImmediate(obj); + } + + createdObjects.Clear(); + } + + [Test] + public void TestBasic() + { + var shadow = new ShadowHierarchy(); + + var gameObject = c(new GameObject("tmp")); + + var target = new object(); + bool wasFired = false; + + shadow.RegisterGameObjectListener(gameObject, (o, e) => + { + Assert.AreEqual(target, o); + Assert.AreEqual(HierarchyEvent.ObjectDirty, e); + wasFired = true; + return false; + }, target); + + shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); + Assert.IsTrue(wasFired); + + wasFired = false; + + shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); + Assert.IsTrue(wasFired); + } + + [Test] + public void ListenerDeregisteredAfterTrueReturn() + { + var shadow = new ShadowHierarchy(); + var gameObject = c(new GameObject("tmp")); + + int count = 0; + var target = new object(); + + shadow.RegisterGameObjectListener(gameObject, (o, e) => + { + count++; + return true; + }, target); + + shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); + shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); + + Assert.AreEqual(1, count); + } + + void MakeListener__WhenTargetGCd_ListenerIsRemoved(ShadowHierarchy h, GameObject gameObject, bool[] wasFired) + { + h.RegisterGameObjectListener(gameObject, (o,e ) => + { + wasFired[0] = true; + return false; + }, new object()); + } + [Test] + public void WhenTargetGCd_ListenerIsRemoved() + { + var shadow = new ShadowHierarchy(); + + var gameObject = c(new GameObject("tmp")); + + var target = new object(); + bool[] wasFired = {false}; + + // Ensure we don't have extra references on the stack still by creating the target object in a separate + // stack frame + MakeListener__WhenTargetGCd_ListenerIsRemoved(shadow, gameObject, wasFired); + + System.GC.Collect(999, GCCollectionMode.Forced, true); + System.GC.WaitForPendingFinalizers(); + + shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); + + Assert.IsFalse(wasFired[0]); + } + + [Test] + public void WhenDisposed_ListenerIsRemoved() + { + var shadow = new ShadowHierarchy(); + + var gameObject = c(new GameObject("tmp")); + + var target = new object(); + bool wasFired = false; + + var listener = shadow.RegisterGameObjectListener(gameObject, (o, e) => + { + Assert.AreEqual(target, e); + Assert.AreEqual(HierarchyEvent.ObjectDirty, o); + wasFired = true; + return false; + }, target); + + listener.Dispose(); + shadow.FireObjectChangeNotification(gameObject.GetInstanceID()); + + Assert.IsFalse(wasFired); + } + + [Test] + public void PathNotifications_GeneratedWhenImmediateParentChanged() + { + var shadow = new ShadowHierarchy(); + var p1 = c("p1"); + var p2 = c("p2"); + + var target = new object(); + bool wasFired = false; + + shadow.RegisterGameObjectListener(p2, (o, e) => + { + if (e == HierarchyEvent.PathChange) + { + wasFired = true; + } + + return false; + }, target); + shadow.EnablePathMonitoring(p2); + + p2.transform.SetParent(p1.transform); + + shadow.FireReparentNotification(p2.GetInstanceID()); + + Assert.IsTrue(wasFired); + } + + [Test] + public void PathNotifications_GeneratedWhenGrandparentChanged() + { + var shadow = new ShadowHierarchy(); + var p1 = c("p1"); + var p2 = c("p2"); + var p3 = c("p3"); + + var target = new object(); + bool wasFired = false; + + p1.transform.SetParent(p2.transform); + + shadow.RegisterGameObjectListener(p1, (o, e) => + { + if (e == HierarchyEvent.PathChange) + { + wasFired = true; + } + + return false; + }, target); + shadow.EnablePathMonitoring(p1); + + p2.transform.SetParent(p3.transform); + + shadow.FireReparentNotification(p2.GetInstanceID()); + + Assert.IsTrue(wasFired); + } + + [Test] + public void ComponentChangeNotifications_GeneratedWhenObjectItselfChanges() + { + var shadow = new ShadowHierarchy(); + var obj = c("obj"); + + List events = new List(); + + shadow.RegisterGameObjectListener(obj, (o, e) => + { + events.Add(e); + return false; + }, events); + + shadow.FireStructureChangeEvent(obj.GetInstanceID()); + + Assert.AreEqual(1, events.Count); + Assert.AreEqual(HierarchyEvent.SelfComponentsChanged, events[0]); + } + + [Test] + public void ComponentChangeNotifications_GeneratedWhenChildChanges() + { + var shadow = new ShadowHierarchy(); + var parent = c("p"); + var child = c("c"); + + child.transform.SetParent(parent.transform); + + List events = new List(); + + shadow.RegisterGameObjectListener(parent, (o, e) => + { + events.Add(e); + return false; + }, events); + + shadow.EnableComponentMonitoring(parent); + shadow.FireStructureChangeEvent(child.GetInstanceID()); + + Assert.AreEqual(1, events.Count); + Assert.AreEqual(HierarchyEvent.ChildComponentsChanged, events[0]); + } + + [Test] + public void ComponentChangeNotifications_FiredAfterReparents() + { + var shadow = new ShadowHierarchy(); + var p1 = c("p1"); + var p2 = c("p2"); + var p3 = c("p3"); + + p3.transform.SetParent(p2.transform); + + List events = new List(); + + shadow.RegisterGameObjectListener(p1, (o, e) => + { + events.Add(e); + return false; + }, events); + + shadow.EnableComponentMonitoring(p1); + + p2.transform.SetParent(p1.transform); + shadow.FireReparentNotification(p2.GetInstanceID()); + + // Assert.AreEqual(1, events.Count); - TODO - deduplicate events + Assert.IsFalse(events.Contains(HierarchyEvent.PathChange)); // we didn't register for this + Assert.IsTrue(events.Contains(HierarchyEvent.ChildComponentsChanged)); + + events.Clear(); + + shadow.FireStructureChangeEvent(p3.GetInstanceID()); + + // Assert.AreEqual(1, events.Count); - TODO - deduplicate events + Assert.IsTrue(events.Contains(HierarchyEvent.ChildComponentsChanged)); + } + + [Test] + public void ComponentChangeNotification_FiredAfterReorderEvent() + { + var shadow = new ShadowHierarchy(); + var p = c("p"); + var c1 = c("c1"); + + c1.transform.SetParent(p.transform); + + List events = new List(); + + shadow.RegisterGameObjectListener(p, (o, e) => + { + events.Add(e); + return false; + }, events); + + shadow.EnableComponentMonitoring(p); + + shadow.FireReorderNotification(c1.GetInstanceID()); + + Assert.AreEqual(1, events.Count); + Assert.IsTrue(events.Contains(HierarchyEvent.ChildComponentsChanged)); + } + + [Test] + public void OnDestroy_NotificationsBlasted() + { + var shadow = new ShadowHierarchy(); + + var o1 = c("o1"); + var o2 = c("o2"); + var o3 = c("o3"); + + o2.transform.SetParent(o1.transform); + o3.transform.SetParent(o2.transform); + + List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); + shadow.RegisterGameObjectListener(o1, (o, e) => + { + events.Add(((int) o, e)); + return false; + }, 1); + shadow.RegisterGameObjectListener(o2, (o, e) => + { + events.Add(((int) o, e)); + return false; + }, 2); + shadow.RegisterGameObjectListener(o3, (o, e) => + { + events.Add(((int) o, e)); + return false; + }, 3); + + shadow.EnableComponentMonitoring(o1); + + var o2_id = o2.GetInstanceID(); + Object.DestroyImmediate(o2); + + shadow.FireDestroyNotification(o2_id); + + Assert.Contains((1, HierarchyEvent.ChildComponentsChanged), events); + Assert.Contains((2, HierarchyEvent.ForceInvalidate), events); + Assert.Contains((3, HierarchyEvent.ForceInvalidate), events); + } + + private ListenerSet.Invokee AddToList(List<(int, HierarchyEvent)> events) + { + return (o, e) => + { + events.Add(((int) o, e)); + return false; + }; + } + + [Test] + public void OnReparentDestroyedObject_NotificationsBlasted() + { + var shadow = new ShadowHierarchy(); + + var o1 = c("o1"); + var o2 = c("o2"); + var o3 = c("o3"); + + o2.transform.SetParent(o1.transform); + o3.transform.SetParent(o2.transform); + + List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); + shadow.RegisterGameObjectListener(o1, AddToList(events), 1); + shadow.RegisterGameObjectListener(o2, AddToList(events), 2); + shadow.RegisterGameObjectListener(o3, AddToList(events), 3); + + shadow.EnableComponentMonitoring(o1); + + var o2_id = o2.GetInstanceID(); + Object.DestroyImmediate(o2); + + shadow.FireReparentNotification(o2_id); + + Assert.Contains((1, HierarchyEvent.ChildComponentsChanged), events); + Assert.Contains((2, HierarchyEvent.ForceInvalidate), events); + Assert.Contains((3, HierarchyEvent.ForceInvalidate), events); + } + + [Test] + public void OnInvalidateAll_EverythingIsInvalidated() + { + var shadow = new ShadowHierarchy(); + + var o1 = c("o1"); + var o2 = c("o2"); + var o3 = c("o3"); + + o2.transform.SetParent(o1.transform); + o3.transform.SetParent(o2.transform); + + List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); + shadow.RegisterGameObjectListener(o1, AddToList(events), 1); + shadow.RegisterGameObjectListener(o2, AddToList(events), 2); + shadow.RegisterGameObjectListener(o3, AddToList(events), 3); + + shadow.InvalidateAll(); + shadow.FireObjectChangeNotification(o1.GetInstanceID()); // should be ignored + + Assert.Contains((1, HierarchyEvent.ForceInvalidate), events); + Assert.Contains((2, HierarchyEvent.ForceInvalidate), events); + Assert.Contains((3, HierarchyEvent.ForceInvalidate), events); + Assert.IsFalse(events.Contains((1, HierarchyEvent.ObjectDirty))); + } + + [Test] + public void ComponentMonitoringTest() + { + var shadow = new ShadowHierarchy(); + + var o1 = c("o1"); + var component = o1.AddComponent(); + + List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); + shadow.RegisterObjectListener(component, AddToList(events), 1); + + shadow.FireObjectChangeNotification(component.GetInstanceID()); + + Assert.Contains((1, HierarchyEvent.ObjectDirty), events); + } + + [Test] + public void ComponentReorder_TriggersStructureChange() + { + var shadow = new ShadowHierarchy(); + + var o1 = c("o1"); + var o2 = c("o2"); + + var c1 = o2.AddComponent(); + var c2 = o2.AddComponent(); + + var (iid1, iid2) = (c1.GetInstanceID(), c2.GetInstanceID()); + + o2.transform.SetParent(o1.transform); + + List<(int, HierarchyEvent)> events = new List<(int, HierarchyEvent)>(); + shadow.RegisterGameObjectListener(o1, AddToList(events), 1); + shadow.RegisterGameObjectListener(o2, AddToList(events), 2); + + shadow.EnableComponentMonitoring(o1); + + UnityEditorInternal.ComponentUtility.MoveComponentUp(c2); + var newComponents = o2.GetComponents(); + var (iid1a, iid2a) = (newComponents[0].GetInstanceID(), newComponents[1].GetInstanceID()); + + Assert.AreEqual(iid1, iid2a); + Assert.AreEqual(iid2, iid1a); + + shadow.FireObjectChangeNotification(iid1); + shadow.FireObjectChangeNotification(iid2); + + Assert.Contains((1, HierarchyEvent.ChildComponentsChanged), events); + Assert.Contains((2, HierarchyEvent.SelfComponentsChanged), events); + } + + // TODO - hierarchy pruning + // TODO - test create game object notifications + // TODO - test asset objects + } +} \ No newline at end of file diff --git a/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs.meta b/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs.meta new file mode 100644 index 00000000..8fc9fbbe --- /dev/null +++ b/UnitTests~/RQ-EditorTests/ShadowHierarchyTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8a9e3b2f42454cf086c8986e4ddd8e11 +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef b/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef new file mode 100644 index 00000000..98223188 --- /dev/null +++ b/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef @@ -0,0 +1,20 @@ +{ + "name": "nadena.dev.ndmf.reactive-query.tests.editor", + "rootNamespace": "", + "references": [ + "nadena.dev.ndmf.reactive-query.core", + "nadena.dev.ndmf.reactive-query.tests", + "nadena.dev.ndmf" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef.meta b/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef.meta new file mode 100644 index 00000000..f8fe9ee0 --- /dev/null +++ b/UnitTests~/RQ-EditorTests/nadena.dev.ndmf.reactive-query.tests.editor.asmdef.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8c5771c0a6864b6b85da6f23b1b831aa +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests.meta b/UnitTests~/RQ-StandaloneTests.meta new file mode 100644 index 00000000..afddb7a0 --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f21f3d75ed5b4cba856641c4f732ed15 +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/BasicQueryTest.cs b/UnitTests~/RQ-StandaloneTests/BasicQueryTest.cs new file mode 100644 index 00000000..4913ef40 --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/BasicQueryTest.cs @@ -0,0 +1,144 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace nadena.dev.ndmf.rq.StandaloneTests +{ + public class BasicQueryTest : StandaloneTestBase + { + [Test] + [Timeout(5000)] + public async Task TrivialQuery() + { + var q = new TestQuery(_ => Task.FromResult(42)); + + Assert.IsFalse(q.TryGetValue(out var _)); + var task = q.GetValueAsync(); + + Assert.AreEqual(42, await task.Timeout()); + Assert.IsTrue(q.TryGetValue(out var result)); + Assert.AreEqual(42, result); + } + + [Test] + [Timeout(5000)] + public async Task CacheAndInvalidate() + { + int value = 1; + + var q = new TestQuery(_ => Task.FromResult(value)); + + Assert.AreEqual(1, await q.GetValueAsync().Timeout()); + value = 2; + Assert.AreEqual(1, await q.GetValueAsync().Timeout()); + q.Invalidate(); + Assert.AreEqual(2, await q.GetValueAsync().Timeout()); + } + + [Test] + [Timeout(5000)] + public async Task ChainedInvalidation() + { + int value = 1; + + var q1 = new TestQuery(_ => Task.FromResult(value)); + var q2 = new TestQuery(async ctx => await ctx.Observe(q1)); + + Assert.AreEqual(1, await q2.GetValueAsync().Timeout()); + + value = 2; + q1.Invalidate(); + + Assert.AreEqual(2, await q2.GetValueAsync().Timeout()); + } + + [Test] + [Timeout(5000)] + public async Task TaskDelay() + { + TaskCompletionSource tcs = new TaskCompletionSource(); + var q = new TestQuery(_ => tcs.Task); + var q2 = new TestQuery(async ctx => await ctx.Observe(q)); + + var t2 = q2.GetValueAsync(); + await Task.Delay(100); + Assert.IsFalse(t2.IsCompleted); + + tcs.SetResult(42); + Assert.AreEqual(42, await t2.Timeout()); + } + + [Test] + [Timeout(5000)] + public async Task CancellationAwaitsPreviousExecution() + { + TaskCompletionSource tcs = new TaskCompletionSource(); + var q = new TestQuery(_ => tcs.Task); + + var task = q.GetValueAsync(); + await Task.Delay(100); + + q.Value = _ => Task.FromResult(42); + + var task2 = q.GetValueAsync(); + await Task.Delay(100); + Assert.IsFalse(task2.IsCompleted); + + tcs.SetResult(123); + Assert.AreEqual(42, await task2.Timeout(100)); + } + + [Test] + [Timeout(5000)] + public async Task ObserveMultipleQueries() + { + var q1 = new TestQuery(_ => Task.FromResult(1)); + var q2 = new TestQuery(_ => Task.FromResult(2)); + var q3 = new TestQuery(async ctx => await ctx.Observe(q1) + await ctx.Observe(q2)); + + Assert.AreEqual(3, await q3.GetValueAsync().Timeout()); + + q2.Value = _ => Task.FromResult(30); + Assert.AreEqual(31, await q3.GetValueAsync().Timeout()); + + q1.Value = _ => Task.FromResult(10); + Assert.AreEqual(40, await q3.GetValueAsync().Timeout()); + } + + [Test] + //[Timeout(5000)] + public async Task StopObserving() + { + var counter = 1; + var q1 = new TestQuery(_ => Task.FromResult(counter++)); + + var shouldCheck = new TestQuery(_ => Task.FromResult(true)); + var q2 = new TestQuery(async ctx => + { + Console.WriteLine("L1"); + var check = await ctx.Observe(shouldCheck); + Console.WriteLine("L2 " + check); + if (check) + { + var observe = await ctx.Observe(q1); + Console.WriteLine("L3 " + observe); + return observe; + } + else + { + Console.WriteLine("L4"); + return 0; + } + }); + + Assert.AreEqual(1, await q2.GetValueAsync().Timeout()); + q1.Invalidate(); + Assert.AreEqual(2, await q2.GetValueAsync().Timeout()); + shouldCheck.Value = _ => Task.FromResult(false); + Assert.AreEqual(0, await q2.GetValueAsync().Timeout()); + q1.Invalidate(); + Assert.AreEqual(0, await q2.GetValueAsync().Timeout()); + } + } +} \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/BasicQueryTest.cs.meta b/UnitTests~/RQ-StandaloneTests/BasicQueryTest.cs.meta new file mode 100644 index 00000000..d2aad53b --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/BasicQueryTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d0401f4531a24ed394830a0dc2db3c56 +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/ObservableInterfaceTest.cs b/UnitTests~/RQ-StandaloneTests/ObservableInterfaceTest.cs new file mode 100644 index 00000000..4098d0cf --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/ObservableInterfaceTest.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace nadena.dev.ndmf.rq.StandaloneTests +{ + public class ObservableInterfaceTest : StandaloneTestBase + { + class TestObserver : IObserver + { + public volatile TaskCompletionSource TaskCompletionSource = new TaskCompletionSource(); + public List Sequence = new List(); + + public void OnCompleted() + { + if (TaskCompletionSource?.Task.IsCompleted != true) TaskCompletionSource?.SetResult(null); + Sequence.Add(null); + } + + public void OnError(Exception error) + { + if (TaskCompletionSource?.Task.IsCompleted != true) TaskCompletionSource?.SetResult(null); + Sequence.Add(error); + } + + public void OnNext(T value) + { + if (TaskCompletionSource?.Task.IsCompleted != true) TaskCompletionSource?.SetResult(null); + Sequence.Add(value); + } + } + + // We don't want things to be running on the unity main thread as that'll be busy in a blocking wait... + + private class ThreadPoolSyncContext : SynchronizationContext + { + public override void Post(SendOrPostCallback d, object state) + { + ThreadPool.QueueUserWorkItem(_ => d(state)); + } + + public override void Send(SendOrPostCallback d, object state) + { + d(state); + } + } + + private SynchronizationContext _priorSyncContext, _priorOverrideSyncContext; + [OneTimeSetUp] + public void Setup() + { + SynchronizationContext threadPoolSyncContext = new ThreadPoolSyncContext(); + + _priorSyncContext = SynchronizationContext.Current; + _priorOverrideSyncContext = ReactiveQueryScheduler.SynchronizationContextOverride.Value; + + SynchronizationContext.SetSynchronizationContext(threadPoolSyncContext); + ReactiveQueryScheduler.SynchronizationContextOverride.Value = threadPoolSyncContext; + } + + [OneTimeTearDown] + public void TearDown() + { + SynchronizationContext.SetSynchronizationContext(_priorSyncContext); + ReactiveQueryScheduler.SynchronizationContextOverride.Value = _priorOverrideSyncContext; + } + + [Test] + [Timeout(2000)] + public void ObservableBasicTest() + { + var rq = new TestQuery(_ => Task.FromResult(42)); + var observer = new TestObserver(); + observer.TaskCompletionSource = new TaskCompletionSource(); + + var remover = rq.Subscribe(observer); + Task.WaitAll(new Task[] { observer.TaskCompletionSource.Task }, 100); + Assert.AreEqual(new List() { 42 }, observer.Sequence); + + observer.TaskCompletionSource = new TaskCompletionSource(); + rq.Value = _ => Task.FromResult(43); + Task.WaitAll(new Task[] { observer.TaskCompletionSource.Task }, 100); + Assert.AreEqual(new List() { 42, 43 }, observer.Sequence); + + remover.Dispose(); + + observer.TaskCompletionSource = new TaskCompletionSource(); + rq.Value = _ => Task.FromResult(44); + Task.WaitAll(new Task[] { observer.TaskCompletionSource.Task }, 100); + Assert.AreEqual(new List() { 42, 43, null }, observer.Sequence); + } + + class BarrierObserver : IObserver + { + public volatile int ObservationCount = 0; + public volatile bool Broken = false; + public Barrier Barrier = new Barrier(2); + + private int _isActive; + private int _index; + + public BarrierObserver(int index) + { + _isActive = 0; + _index = index; + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void OnNext(int value) + { + System.Console.Error.WriteLine($"Observer {_index} got value {value}"); + Interlocked.Increment(ref ObservationCount); + + var wasActive = Interlocked.Exchange(ref _isActive, 1); + if (wasActive == 1) + { + Broken = true; + Barrier.Dispose(); + } + + Barrier.SignalAndWait(); + Barrier.SignalAndWait(); + + _isActive = 0; + } + } + + [Test] + [Timeout(2000)] + public void LimitsParallelismInObserverScope() + { + Barrier barrier = new Barrier(2); + + var rq = new TestQuery(_ => Task.FromResult(42)); + + var o1 = new BarrierObserver(1); + var o2 = new BarrierObserver(2); + + IDisposable d1, d2; + + d1 = rq.Subscribe(o1); + d2 = rq.Subscribe(o2); + + Thread.Sleep(100); + + try + { + Assert.IsTrue(o1.Barrier.SignalAndWait(100)); + Assert.IsFalse(o1.Broken); + Assert.AreEqual(1, o1.ObservationCount); + Assert.IsTrue(o1.Barrier.SignalAndWait(100)); + + rq.Invalidate(); + + Assert.IsTrue(o1.Barrier.SignalAndWait(100)); + Assert.IsFalse(o1.Broken); + Assert.AreEqual(2, o1.ObservationCount); + Assert.IsTrue(o1.Barrier.SignalAndWait(100)); + + rq.Invalidate(); + + Assert.IsTrue(o1.Barrier.SignalAndWait(100)); + Assert.IsFalse(o1.Broken); + Assert.AreEqual(3, o1.ObservationCount); + Assert.IsTrue(o1.Barrier.SignalAndWait(100)); + + // o2 was blocked all this time, let's go check on it now + Assert.IsTrue(o2.Barrier.SignalAndWait(100)); + Assert.IsFalse(o2.Broken); + Assert.AreEqual(1, o2.ObservationCount); + Assert.IsTrue(o2.Barrier.SignalAndWait(100)); + Assert.IsTrue(o2.Barrier.SignalAndWait(100)); + Assert.IsFalse(o2.Broken); + Assert.AreEqual(2, o2.ObservationCount); + Assert.IsTrue(o2.Barrier.SignalAndWait(100)); + Assert.IsTrue(o2.Barrier.SignalAndWait(100)); + Assert.IsFalse(o2.Broken); + Assert.AreEqual(3, o2.ObservationCount); + Assert.IsTrue(o2.Barrier.SignalAndWait(100)); + + d1.Dispose(); + d2.Dispose(); + } + finally + { + o1.Barrier.Dispose(); + o2.Barrier.Dispose(); + } + } + + [Test] + [Timeout(2000)] + public void ErrorReportingTest() + { + var ex = new Exception("Test exception"); + var rq = new TestQuery(_ => Task.FromException(ex)); + + var observer = new TestObserver(); + observer.TaskCompletionSource = new TaskCompletionSource(); + + rq.Subscribe(observer); + + Task.WaitAll(new Task[] { observer.TaskCompletionSource.Task }, 100); + Assert.AreEqual(new List() { ex }, observer.Sequence); + } + } +} \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/ObservableInterfaceTest.cs.meta b/UnitTests~/RQ-StandaloneTests/ObservableInterfaceTest.cs.meta new file mode 100644 index 00000000..74b9fd40 --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/ObservableInterfaceTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8c1be259a5264f1a83cd448647cf9934 +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/StandaloneTestBase.cs b/UnitTests~/RQ-StandaloneTests/StandaloneTestBase.cs new file mode 100644 index 00000000..3a6d3872 --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/StandaloneTestBase.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; +using NUnit.Framework; + +namespace nadena.dev.ndmf.rq.StandaloneTests +{ + public class StandaloneTestBase + { + private TaskFactory priorFactory = ReactiveQueryScheduler.DefaultTaskFactory; + + [OneTimeSetUp] + public void SetTaskFactory() + { + ReactiveQueryScheduler.DefaultTaskFactory = new TaskFactory(TaskScheduler.Default); + } + + [OneTimeTearDown] + public void ResetTaskFactory() + { + ReactiveQueryScheduler.DefaultTaskFactory = priorFactory; + } + } +} \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/StandaloneTestBase.cs.meta b/UnitTests~/RQ-StandaloneTests/StandaloneTestBase.cs.meta new file mode 100644 index 00000000..df39d8ca --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/StandaloneTestBase.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 582fdcaf791942e894ab9ba45475ca6d +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/TestHelpers.cs b/UnitTests~/RQ-StandaloneTests/TestHelpers.cs new file mode 100644 index 00000000..34251b76 --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/TestHelpers.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; + +namespace nadena.dev.ndmf.rq.StandaloneTests +{ + public static class TestHelpers + { + public static async Task Timeout(this Task task, int timeout = 1000) + { + if (await Task.WhenAny(task, Task.Delay(timeout)) == task) + { + return task.Result; + } + else + { + throw new TimeoutException("Task did not complete in time"); + } + } + + public static async Task Timeout(this Task task, int timeout = 1000) + { + if (await Task.WhenAny(task, Task.Delay(timeout)) == task) + { + await task; + } + else + { + throw new TimeoutException("Task did not complete in time"); + } + } + } +} \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/TestHelpers.cs.meta b/UnitTests~/RQ-StandaloneTests/TestHelpers.cs.meta new file mode 100644 index 00000000..95fceae8 --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/TestHelpers.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e2236bee522f4f36953529fe2a1324a0 +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/TestQuery.cs b/UnitTests~/RQ-StandaloneTests/TestQuery.cs new file mode 100644 index 00000000..4156384a --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/TestQuery.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using nadena.dev.ndmf.rq; + +namespace nadena.dev.ndmf.rq.StandaloneTests +{ + internal class TestQuery : ReactiveValue + { + private Func> _value; + + public Func> Value + { + get => _value; + set + { + _value = value; + Invalidate(); + } + } + + public TestQuery(Func> func) + { + _value = func; + } + + protected override async Task Compute(ComputeContext context) + { + return await _value(context); + } + + public override string ToString() + { + return "TestQuery"; + } + } +} \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/TestQuery.cs.meta b/UnitTests~/RQ-StandaloneTests/TestQuery.cs.meta new file mode 100644 index 00000000..e4e0ce38 --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/TestQuery.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2bcd69687b28404d9d739a881293be91 +timeCreated: 1717367926 \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/nadena.dev.ndmf.reactive-query.tests.standalone.asmdef b/UnitTests~/RQ-StandaloneTests/nadena.dev.ndmf.reactive-query.tests.standalone.asmdef new file mode 100644 index 00000000..6f9abc1a --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/nadena.dev.ndmf.reactive-query.tests.standalone.asmdef @@ -0,0 +1,18 @@ +{ + "name": "nadena.dev.ndmf.reactive-query.tests", + "rootNamespace": "", + "references": [ + "nadena.dev.ndmf.reactive-query.core" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} \ No newline at end of file diff --git a/UnitTests~/RQ-StandaloneTests/nadena.dev.ndmf.reactive-query.tests.standalone.asmdef.meta b/UnitTests~/RQ-StandaloneTests/nadena.dev.ndmf.reactive-query.tests.standalone.asmdef.meta new file mode 100644 index 00000000..2a95e765 --- /dev/null +++ b/UnitTests~/RQ-StandaloneTests/nadena.dev.ndmf.reactive-query.tests.standalone.asmdef.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 41f1b2e171db4a0c9ea2fc1de678108e +timeCreated: 1717367926 \ No newline at end of file