diff --git a/ObservableCollections.sln b/ObservableCollections.sln index 9d8ae24..d15f165 100644 --- a/ObservableCollections.sln +++ b/ObservableCollections.sln @@ -23,6 +23,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{7133A3F7 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostBuildUtility", "tools\PostBuildUtility\PostBuildUtility.csproj", "{29E3967D-89E9-494F-B1E6-9706B8F1CD57}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObservableCollections.R3", "src\ObservableCollections.R3\ObservableCollections.R3.csproj", "{D5950521-C5B3-4B92-834E-3B12CDDD8DD6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObservableCollections.R3.Tests", "tests\ObservableCollections.R3.Tests\ObservableCollections.R3.Tests.csproj", "{1205F414-EE6D-49C6-9500-3E62E2120EAF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,6 +57,14 @@ Global {29E3967D-89E9-494F-B1E6-9706B8F1CD57}.Debug|Any CPU.Build.0 = Debug|Any CPU {29E3967D-89E9-494F-B1E6-9706B8F1CD57}.Release|Any CPU.ActiveCfg = Release|Any CPU {29E3967D-89E9-494F-B1E6-9706B8F1CD57}.Release|Any CPU.Build.0 = Release|Any CPU + {D5950521-C5B3-4B92-834E-3B12CDDD8DD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5950521-C5B3-4B92-834E-3B12CDDD8DD6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5950521-C5B3-4B92-834E-3B12CDDD8DD6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5950521-C5B3-4B92-834E-3B12CDDD8DD6}.Release|Any CPU.Build.0 = Release|Any CPU + {1205F414-EE6D-49C6-9500-3E62E2120EAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1205F414-EE6D-49C6-9500-3E62E2120EAF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1205F414-EE6D-49C6-9500-3E62E2120EAF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1205F414-EE6D-49C6-9500-3E62E2120EAF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -64,6 +76,8 @@ Global {7E10EF01-24DC-4346-8A18-F791BB5252A7} = {FD836539-75F1-4707-BCFF-751B95DAE19C} {B84027E4-9B39-4FB6-B888-C55CE4C79152} = {B6D0425C-7902-4EFB-B0EA-99F164C20835} {29E3967D-89E9-494F-B1E6-9706B8F1CD57} = {7133A3F7-B398-4DE0-8295-0F1ECFCC4CE4} + {D5950521-C5B3-4B92-834E-3B12CDDD8DD6} = {8F60DC54-F617-4841-8C79-6B0137500D1C} + {1205F414-EE6D-49C6-9500-3E62E2120EAF} = {B6D0425C-7902-4EFB-B0EA-99F164C20835} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4156A725-69F4-469F-9BBB-0EE9921CA83E} diff --git a/src/ObservableCollections.R3/ObservableCollectionR3Extensions.cs b/src/ObservableCollections.R3/ObservableCollectionR3Extensions.cs new file mode 100644 index 0000000..460f690 --- /dev/null +++ b/src/ObservableCollections.R3/ObservableCollectionR3Extensions.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Specialized; +using System.Threading; +using R3; + +namespace ObservableCollections.R3; + +public readonly record struct CollectionAddEvent(int Index, T Value); +public readonly record struct CollectionRemoveEvent(int Index, T Value); +public readonly record struct CollectionReplaceEvent(int Index, T OldValue, T NewValue); +public readonly record struct CollectionMoveEvent(int OldIndex, int NewIndex, T Value); + +public static class ObservableCollectionR3Extensions +{ + public static Observable> ObserveAdd(this IObservableCollection source, CancellationToken cancellationToken = default) + { + return new ObservableCollectionAdd(source, cancellationToken); + } + + public static Observable> ObserveRemove(this IObservableCollection source, CancellationToken cancellationToken = default) + { + return new ObservableCollectionRemove(source, cancellationToken); + } + + public static Observable> ObserveReplace(this IObservableCollection source, CancellationToken cancellationToken = default) + { + return new ObservableCollectionReplace(source, cancellationToken); + } + + public static Observable> ObserveMove(this IObservableCollection source, CancellationToken cancellationToken = default) + { + return new ObservableCollectionMove(source, cancellationToken); + } + + public static Observable ObserveReset(this IObservableCollection source, CancellationToken cancellationToken = default) + { + return new ObservableCollectionReset(source, cancellationToken); + } +} + +sealed class ObservableCollectionAdd(IObservableCollection collection, CancellationToken cancellationToken) + : Observable> +{ + protected override IDisposable SubscribeCore(Observer> observer) + { + return new _ObservableCollectionAdd(collection, observer, cancellationToken); + } + + sealed class _ObservableCollectionAdd( + IObservableCollection collection, + Observer> observer, + CancellationToken cancellationToken) + : ObservableCollectionObserverBase>(collection, observer, cancellationToken) + { + protected override void Handler(in NotifyCollectionChangedEventArgs eventArgs) + { + if (eventArgs.Action == NotifyCollectionChangedAction.Add) + { + if (eventArgs.IsSingleItem) + { + observer.OnNext(new CollectionAddEvent(eventArgs.NewStartingIndex, eventArgs.NewItem)); + } + else + { + var i = eventArgs.NewStartingIndex; + foreach (var item in eventArgs.NewItems) + { + observer.OnNext(new CollectionAddEvent(i++, item)); + } + } + } + } + } +} + +sealed class ObservableCollectionRemove(IObservableCollection collection, CancellationToken cancellationToken) + : Observable> +{ + protected override IDisposable SubscribeCore(Observer> observer) + { + return new _ObservableCollectionRemove(collection, observer, cancellationToken); + } + + sealed class _ObservableCollectionRemove( + IObservableCollection collection, + Observer> observer, + CancellationToken cancellationToken) + : ObservableCollectionObserverBase>(collection, observer, cancellationToken) + { + protected override void Handler(in NotifyCollectionChangedEventArgs eventArgs) + { + if (eventArgs.Action == NotifyCollectionChangedAction.Remove) + { + if (eventArgs.IsSingleItem) + { + observer.OnNext(new CollectionRemoveEvent(eventArgs.OldStartingIndex, eventArgs.OldItem)); + } + else + { + var i = eventArgs.OldStartingIndex; + foreach (var item in eventArgs.OldItems) + { + observer.OnNext(new CollectionRemoveEvent(i++, item)); + } + } + } + } + } +} + +sealed class ObservableCollectionReplace(IObservableCollection collection, CancellationToken cancellationToken) + : Observable> +{ + protected override IDisposable SubscribeCore(Observer> observer) + { + return new _ObservableCollectionReplace(collection, observer, cancellationToken); + } + + sealed class _ObservableCollectionReplace( + IObservableCollection collection, + Observer> observer, + CancellationToken cancellationToken) + : ObservableCollectionObserverBase>(collection, observer, cancellationToken) + { + protected override void Handler(in NotifyCollectionChangedEventArgs eventArgs) + { + if (eventArgs.Action == NotifyCollectionChangedAction.Replace) + { + observer.OnNext(new CollectionReplaceEvent(eventArgs.NewStartingIndex, eventArgs.OldItem, eventArgs.NewItem)); + } + } + } +} + +sealed class ObservableCollectionMove(IObservableCollection collection, CancellationToken cancellationToken) + : Observable> +{ + protected override IDisposable SubscribeCore(Observer> observer) + { + return new _ObservableCollectionMove(collection, observer, cancellationToken); + } + + sealed class _ObservableCollectionMove( + IObservableCollection collection, + Observer> observer, + CancellationToken cancellationToken) + : ObservableCollectionObserverBase>(collection, observer, cancellationToken) + { + protected override void Handler(in NotifyCollectionChangedEventArgs eventArgs) + { + if (eventArgs.Action == NotifyCollectionChangedAction.Move) + { + observer.OnNext(new CollectionMoveEvent(eventArgs.OldStartingIndex, eventArgs.NewStartingIndex, eventArgs.NewItem)); + } + } + } +} + +sealed class ObservableCollectionReset(IObservableCollection collection, CancellationToken cancellationToken) + : Observable +{ + protected override IDisposable SubscribeCore(Observer observer) + { + return new _ObservableCollectionReset(collection, observer, cancellationToken); + } + + sealed class _ObservableCollectionReset( + IObservableCollection collection, + Observer observer, + CancellationToken cancellationToken) + : ObservableCollectionObserverBase(collection, observer, cancellationToken) + { + protected override void Handler(in NotifyCollectionChangedEventArgs eventArgs) + { + if (eventArgs.Action == NotifyCollectionChangedAction.Reset) + { + observer.OnNext(Unit.Default); + } + } + } +} + +abstract class ObservableCollectionObserverBase : IDisposable +{ + protected readonly IObservableCollection collection; + protected readonly Observer observer; + readonly CancellationTokenRegistration cancellationTokenRegistration; + readonly NotifyCollectionChangedEventHandler handlerDelegate; + + public ObservableCollectionObserverBase(IObservableCollection collection, Observer observer, CancellationToken cancellationToken) + { + this.collection = collection; + this.observer = observer; + this.handlerDelegate = Handler; + + collection.CollectionChanged += handlerDelegate; + + if (cancellationToken.CanBeCanceled) + { + cancellationTokenRegistration = cancellationToken.UnsafeRegister(static state => + { + var s = (ObservableCollectionObserverBase)state!; + s.observer.OnCompleted(); + s.Dispose(); + }, this); + } + } + + public void Dispose() + { + collection.CollectionChanged -= handlerDelegate; + cancellationTokenRegistration.Dispose(); + } + + protected abstract void Handler(in NotifyCollectionChangedEventArgs eventArgs); +} diff --git a/src/ObservableCollections.R3/ObservableCollections.R3.csproj b/src/ObservableCollections.R3/ObservableCollections.R3.csproj new file mode 100644 index 0000000..fa7e49b --- /dev/null +++ b/src/ObservableCollections.R3/ObservableCollections.R3.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0;netstandard2.1;net6.0;net8.0 + disable + enable + 12 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/ObservableCollections.R3/Shims.cs b/src/ObservableCollections.R3/Shims.cs new file mode 100644 index 0000000..0f10ae5 --- /dev/null +++ b/src/ObservableCollections.R3/Shims.cs @@ -0,0 +1,13 @@ +#if NETSTANDARD2_0 || NETSTANDARD2_1 + +namespace System.Threading; + +internal static class CancellationTokenExtensions +{ + public static CancellationTokenRegistration UnsafeRegister(this CancellationToken cancellationToken, Action callback, object? state) + { + return cancellationToken.Register(callback, state, useSynchronizationContext: false); + } +} + +#endif \ No newline at end of file diff --git a/tests/ObservableCollections.R3.Tests/ObservableCollectionExtensionsTest.cs b/tests/ObservableCollections.R3.Tests/ObservableCollectionExtensionsTest.cs new file mode 100644 index 0000000..b7044d4 --- /dev/null +++ b/tests/ObservableCollections.R3.Tests/ObservableCollectionExtensionsTest.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Threading; +using R3; + +namespace ObservableCollections.R3.Tests; + +public class ObservableCollectionExtensionsTest +{ + [Fact] + public void ObserveAdd() + { + var events = new List>(); + + var collection = new ObservableList(); + + var subscription = collection.ObserveAdd().Subscribe(ev => events.Add(ev)); + collection.Add(10); + collection.Add(50); + collection.Add(30); + + events.Count.Should().Be(3); + events[0].Index.Should().Be(0); + events[0].Value.Should().Be(10); + events[1].Index.Should().Be(1); + events[1].Value.Should().Be(50); + events[2].Index.Should().Be(2); + events[2].Value.Should().Be(30); + + subscription.Dispose(); + + collection.Add(100); + events.Count.Should().Be(3); + } + + [Fact] + public void ObserveAdd_CancellationToken() + { + var cts = new CancellationTokenSource(); + var events = new List>(); + var result = default(Result?); + + var collection = new ObservableList(); + + var subscription = collection.ObserveAdd(cts.Token).Subscribe(ev => events.Add(ev), x => result = x); + collection.Add(10); + collection.Add(50); + collection.Add(30); + + events.Count.Should().Be(3); + + cts.Cancel(); + + result.HasValue.Should().BeTrue(); + + subscription.Dispose(); + + collection.Add(100); + events.Count.Should().Be(3); + } + + [Fact] + public void ObserveRemove() + { + var events = new List>(); + var collection = new ObservableList([111, 222, 333]); + var cts = new CancellationTokenSource(); + var result = default(Result?); + + var subscription = collection.ObserveRemove(cts.Token).Subscribe(ev => events.Add(ev), x => result = x); + collection.RemoveAt(1); + + events.Count.Should().Be(1); + events[0].Index.Should().Be(1); + events[0].Value.Should().Be(222); + + cts.Cancel(); + result.HasValue.Should().BeTrue(); + + subscription.Dispose(); + + collection.RemoveAt(0); + events.Count.Should().Be(1); + } + + [Fact] + public void ObserveReplace() + { + var events = new List>(); + var collection = new ObservableList([111, 222, 333]); + var cts = new CancellationTokenSource(); + var result = default(Result?); + + var subscription = collection.ObserveReplace(cts.Token).Subscribe(ev => events.Add(ev), x => result = x); + collection[1] = 999; + + events.Count.Should().Be(1); + events[0].Index.Should().Be(1); + events[0].OldValue.Should().Be(222); + events[0].NewValue.Should().Be(999); + + cts.Cancel(); + result.HasValue.Should().BeTrue(); + + subscription.Dispose(); + + collection[1] = 444; + events.Count.Should().Be(1); + } + + [Fact] + public void ObserveMove() + { + var events = new List>(); + var collection = new ObservableList([111, 222, 333]); + var cts = new CancellationTokenSource(); + var result = default(Result?); + + var subscription = collection.ObserveMove(cts.Token).Subscribe(ev => events.Add(ev), x => result = x); + + collection.Move(1, 2); + + events.Count.Should().Be(1); + events[0].OldIndex.Should().Be(1); + events[0].NewIndex.Should().Be(2); + events[0].Value.Should().Be(222); + + cts.Cancel(); + result.HasValue.Should().BeTrue(); + + subscription.Dispose(); + + collection.Move(1, 2); + events.Count.Should().Be(1); + } +} \ No newline at end of file diff --git a/tests/ObservableCollections.R3.Tests/ObservableCollections.R3.Tests.csproj b/tests/ObservableCollections.R3.Tests/ObservableCollections.R3.Tests.csproj new file mode 100644 index 0000000..5412267 --- /dev/null +++ b/tests/ObservableCollections.R3.Tests/ObservableCollections.R3.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/ObservableCollections.R3.Tests/_GlobalUsings.cs b/tests/ObservableCollections.R3.Tests/_GlobalUsings.cs new file mode 100644 index 0000000..7fef4b0 --- /dev/null +++ b/tests/ObservableCollections.R3.Tests/_GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Xunit; +global using FluentAssertions; \ No newline at end of file