diff --git a/Source/Collections/AsyncVirtualObservableReadOnlyList.cs b/Source/Collections/AsyncVirtualObservableReadOnlyList.cs
new file mode 100644
index 0000000..4b19388
--- /dev/null
+++ b/Source/Collections/AsyncVirtualObservableReadOnlyList.cs
@@ -0,0 +1,656 @@
+#region Apache License 2.0
+/*
+Nuclex .NET Framework
+Copyright (C) 2002-2024 Markus Ewald / Nuclex Development Labs
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+#endregion // Apache License 2.0
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+
+#if !NO_SPECIALIZED_COLLECTIONS
+using System.Collections.Specialized;
+using System.Diagnostics;
+#endif
+
+namespace Nuclex.Support.Collections {
+
+ ///
+ /// List which fires events when items are added or removed, whilst also
+ /// lazily fetching items as needed (for example from a socket or database)
+ ///
+ /// Type of items the list manages
+ public abstract class AsyncVirtualObservableReadOnlyList :
+ IList,
+ IList,
+ ICollection,
+#if !NO_SPECIALIZED_COLLECTIONS
+ INotifyCollectionChanged,
+#endif
+ IObservableCollection {
+
+ #region class Enumerator
+
+ /// Enumerates over the items in a virtual list
+ private class Enumerator : IEnumerator, IEnumerator {
+
+ /// Initializes a new virtual list enumerator
+ /// List whose items will be enumerated
+ public Enumerator(AsyncVirtualObservableReadOnlyList virtualList) {
+ this.virtualList = virtualList;
+ this.currentItemIndex = -1;
+ this.lastMoveNextResult = false;
+
+ Reset();
+ }
+
+ /// Immediately releases all resources owned by the instance
+ public void Dispose() {
+ this.virtualList = null;
+ }
+
+ /// The item at the enumerator's current position
+ public TItem Current {
+ get {
+#if DEBUG
+ checkVersion();
+#endif
+
+ if(this.lastMoveNextResult == false) {
+ throw new InvalidOperationException("Enumerator is not on a valid position");
+ }
+
+ this.virtualList.requireCount();
+ this.virtualList.requirePage(this.currentItemIndex / this.virtualList.pageSize);
+
+ return this.virtualList.typedList[this.currentItemIndex];
+ }
+ }
+
+ /// Advances the enumerator to the next item
+ /// True if there was a next item
+ public bool MoveNext() {
+#if DEBUG
+ checkVersion();
+#endif
+
+ this.virtualList.requireCount();
+
+ // Go forward if there potentially are items remaining. The count may still be
+ // unreliable at this point (due to the uncertain count mechanism that truncates
+ // the list when fetching items finds an earlier end of the list)
+ if(this.currentItemIndex < this.virtualList.assumedCount.Value) {
+
+ // If the enumerator's 'Current' property is never used an the virtual list
+ // uses the dynamic truncation features (for unknown list sizes), then this
+ // enumerator could be moved way past the last element via 'MoveNext()'.
+ this.virtualList.requirePage(this.currentItemIndex / this.virtualList.pageSize);
+ ++this.currentItemIndex; // Accept potentially advancing past the end here
+
+ }
+
+ // Are we on a valid item? If so, return true to indicate the list continued,
+ // otherwise we must have hit the end (or are already past it).
+ return this.lastMoveNextResult = (
+ (this.currentItemIndex < this.virtualList.assumedCount.Value)
+ );
+ }
+
+ /// Resets the enumerator to its initial position
+ public void Reset() {
+ this.currentItemIndex = -1;
+#if DEBUG
+ this.expectedVersion = this.virtualList.version;
+#endif
+ }
+
+ /// The item at the enumerator's current position
+ object IEnumerator.Current {
+ get { return Current; }
+ }
+
+#if DEBUG
+ /// Ensures that the virtual list has not changed
+ private void checkVersion() {
+ if(this.expectedVersion != this.virtualList.version)
+ throw new InvalidOperationException("Virtual list has been modified");
+ }
+#endif
+
+ /// Virtual list the enumerator belongs to
+ private AsyncVirtualObservableReadOnlyList virtualList;
+ /// Index of the item the enumerator currently is in
+ private int currentItemIndex;
+ /// The most recent result returned from MoveNext()
+ private bool lastMoveNextResult;
+#if DEBUG
+ /// Version the virtual list is expected to have
+ private int expectedVersion;
+#endif
+ }
+
+ #endregion // class Enumerator
+
+ /// Raised when an item has been added to the collection
+ public event EventHandler> ItemAdded;
+ /// Raised when an item is removed from the collection
+ public event EventHandler> ItemRemoved;
+ /// Raised when an item is replaced in the collection
+ public event EventHandler> ItemReplaced;
+ /// Raised when the collection is about to be cleared
+ ///
+ /// This could be covered by calling ItemRemoved for each item currently
+ /// contained in the collection, but it is often simpler and more efficient
+ /// to process the clearing of the entire collection as a special operation.
+ ///
+ public event EventHandler Clearing;
+ /// Raised when the collection has been cleared
+ public event EventHandler Cleared;
+
+#if !NO_SPECIALIZED_COLLECTIONS
+ /// Called when the collection has changed
+ public event NotifyCollectionChangedEventHandler CollectionChanged;
+#endif
+
+ ///
+ /// Initializes a new instance of the ObservableList class that is empty.
+ ///
+ ///
+ /// How many items to download in one batch
+ ///
+ ///
+ /// The can be set to one to request items
+ /// individually or to a larger value in order to improve efficiency when
+ /// the source of the items is a database or similar source that gains
+ /// performance from requesting multiple items at once.
+ ///
+ public AsyncVirtualObservableReadOnlyList(int pageSize = 32) {
+ this.typedList = new List();
+ this.objectList = this.typedList as IList;
+ this.pageSize = pageSize;
+ }
+
+ ///
+ /// Marks all items as non-fetched, causing them to be requested again
+ ///
+ ///
+ /// Whether to also clear the items that may already be in memory but would
+ /// get overwritten on the next fetch. If items consume a lot of memory, this
+ /// will make them available for garbage collection.
+ ///
+ public void InvalidateAll(bool purgeItems = false) {
+ if(this.assumedCount.HasValue) { // If not fetched before, no action needed
+ int pageCount = this.fetchedPages.Length;
+ for(int index = 0; index < pageCount; ++index) {
+ this.fetchedPages[index] = false;
+ }
+
+ if(purgeItems) {
+ int itemCount = this.assumedCount.Value;
+ for(int index = 0; index < itemCount; ++index) {
+ this.typedList[index] = default(TItem);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Marks an items as non-fetched, causing it to be requested again on access
+ ///
+ ///
+ /// Index of the item that will be marked as non-fetched
+ ///
+ ///
+ /// Whether to also clear the items that may already be in memory but would
+ /// get overwritten on the next fetch. If items consume a lot of memory, this
+ /// will make them available for garbage collection.
+ ///
+ ///
+ /// Since the list works in pages, this will actually mark the whole page as
+ /// non-fetched, causing all items in the same page to be requested again
+ /// when any of them are next accessed.
+ ///
+ public void Invalidate(int itemIndex, bool purgeItems = false) {
+ if(this.assumedCount.HasValue) { // If not fetched before, no action needed
+ int pageIndex = itemIndex / this.pageSize;
+ this.fetchedPages[pageIndex] = false;
+
+ if(purgeItems) {
+ int count = Math.Min(
+ this.assumedCount.Value - (this.pageSize * pageIndex),
+ this.pageSize
+ );
+ for(int index = itemIndex / this.pageSize; index < count; ++index) {
+ this.typedList[index] = default(TItem);
+ }
+ }
+ }
+ }
+
+ /// Determines the index of the specified item in the list
+ /// Item whose index will be determined
+ /// The index of the item in the list or -1 if not found
+ public int IndexOf(TItem item) {
+ return this.typedList.IndexOf(item);
+ }
+
+ /// Inserts an item into the list at the specified index
+ /// Index the item will be inserted at
+ /// Item that will be inserted into the list
+ public void Insert(int index, TItem item) {
+ throw new NotSupportedException("Cannot insert items into a read-only list");
+ }
+
+ /// Removes the item at the specified index from the list
+ /// Index at which the item will be removed
+ public void RemoveAt(int index) {
+ throw new NotSupportedException("Cannot remove items from a read-only list");
+ }
+
+ /// Accesses the item at the specified index in the list
+ /// Index of the item that will be accessed
+ /// The item at the specified index
+ public TItem this[int index] {
+ get {
+ requireCount();
+ requirePage(index / this.pageSize);
+
+ return this.typedList[index];
+ }
+ set {
+ // Make sure the page is fetched, otherwise, the item would only suddenly
+ // revert to its state in the source when the pages around it is fetchd later.
+ requireCount();
+ requirePage(index / this.pageSize);
+#if DEBUG
+ ++this.version;
+#endif
+ TItem oldItem = this.typedList[index];
+ this.typedList[index] = value;
+ OnReplaced(oldItem, value, index);
+ }
+ }
+
+ /// Adds an item to the end of the list
+ /// Item that will be added to the list
+ public void Add(TItem item) {
+ throw new NotSupportedException("Cannot add items to a read-only list");
+ }
+
+ /// Removes all items from the list
+ public void Clear() {
+ throw new NotSupportedException("Cannot clear a read-only list");
+ }
+
+ /// Checks whether the list contains the specified item
+ /// Item the list will be checked for
+ /// True if the list contains the specified items
+ public bool Contains(TItem item) {
+ requireCount();
+ requireAllPages();
+
+ return this.typedList.Contains(item);
+ }
+
+ /// Copies the contents of the list into an array
+ /// Array the list will be copied into
+ ///
+ /// Index in the target array where the first item will be copied to
+ ///
+ public void CopyTo(TItem[] array, int arrayIndex) {
+ requireCount();
+ requireAllPages();
+
+ this.typedList.CopyTo(array, arrayIndex);
+ }
+
+ /// Total number of items in the list
+ public int Count {
+ get {
+ requireCount();
+ return this.assumedCount.Value;
+ }
+ }
+
+ /// Whether the list is a read-only list
+ public bool IsReadOnly {
+ get { return this.typedList.IsReadOnly; }
+ }
+
+ /// Removes the specified item from the list
+ /// Item that will be removed from the list
+ ///
+ /// True if the item was found and removed from the list, false otherwise
+ ///
+ public bool Remove(TItem item) {
+ throw new NotSupportedException("Cannot remove items from a read-only list");
+ }
+
+ /// Returns an enumerator for the items in the list
+ /// An enumerator for the list's items
+ public IEnumerator GetEnumerator() {
+ return new Enumerator(this);
+ }
+
+ #region IEnumerable implementation
+
+ /// Returns an enumerator for the items in the list
+ /// An enumerator for the list's items
+ IEnumerator IEnumerable.GetEnumerator() {
+ return new Enumerator(this);
+ }
+
+ #endregion // IEnumerable implementation
+
+ #region ICollection implementation
+
+ /// Copies the contents of the list into an array
+ /// Array the list will be copied into
+ ///
+ /// Index in the target array where the first item will be copied to
+ ///
+ void ICollection.CopyTo(Array array, int arrayIndex) {
+ requireCount();
+ requireAllPages();
+
+ this.objectList.CopyTo(array, arrayIndex);
+ }
+
+ /// Whether this list performs thread synchronization
+ bool ICollection.IsSynchronized {
+ get { return this.objectList.IsSynchronized; }
+ }
+
+ /// Synchronization root used by the list to synchronize threads
+ object ICollection.SyncRoot {
+ get { return this.objectList.SyncRoot; }
+ }
+
+ #endregion // ICollection implementation
+
+ #region IList implementation
+
+ /// Adds an item to the list
+ /// Item that will be added to the list
+ ///
+ /// The position at which the item has been inserted or -1 if the item was not inserted
+ ///
+ int IList.Add(object value) {
+ throw new NotSupportedException("Cannot add items into a read-only list");
+ }
+
+ /// Checks whether the list contains the specified item
+ /// Item the list will be checked for
+ /// True if the list contains the specified items
+ bool IList.Contains(object item) {
+ requireCount();
+ requireAllPages();
+ return this.objectList.Contains(item);
+ }
+
+ /// Determines the index of the specified item in the list
+ /// Item whose index will be determined
+ /// The index of the item in the list or -1 if not found
+ int IList.IndexOf(object item) {
+ requireCount();
+ requireAllPages();
+ return this.objectList.IndexOf(item);
+ }
+
+ /// Inserts an item into the list at the specified index
+ /// Index the item will be inserted at
+ /// Item that will be inserted into the list
+ void IList.Insert(int index, object item) {
+ throw new NotSupportedException("Cannot insert items into a read-only list");
+ }
+
+ /// Whether the list is of a fixed size
+ bool IList.IsFixedSize {
+ get { return this.objectList.IsFixedSize; }
+ }
+
+ /// Removes the specified item from the list
+ /// Item that will be removed from the list
+ void IList.Remove(object item) {
+ throw new NotSupportedException("Cannot remove items from a read-only list");
+ }
+
+ /// Accesses the item at the specified index in the list
+ /// Index of the item that will be accessed
+ /// The item at the specified index
+ object IList.this[int index] {
+ get {
+ requireCount();
+ requirePage(index / this.pageSize);
+
+ return this.objectList[index];
+ }
+ set {
+ // Make sure the page is fetched, otherwise, the item would only suddenly
+ // revert to its state in the source when the pages around it is fetchd later.
+ requireCount();
+ requirePage(index / this.pageSize);
+#if DEBUG
+ ++this.version;
+#endif
+ TItem oldItem = this.typedList[index];
+ this.objectList[index] = value;
+ TItem newItem = this.typedList[index];
+ OnReplaced(oldItem, newItem, index);
+ }
+ }
+
+ #endregion // IList implementation
+
+ /// Fires the 'ItemAdded' event
+ /// Item that has been added to the collection
+ /// Index of the added item
+ protected virtual void OnAdded(TItem item, int index) {
+ if(ItemAdded != null) {
+ ItemAdded(this, new ItemEventArgs(item));
+ }
+#if !NO_SPECIALIZED_COLLECTIONS
+ if(CollectionChanged != null) {
+ CollectionChanged(
+ this,
+ new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)
+ );
+ }
+#endif
+ }
+
+ /// Fires the 'ItemRemoved' event
+ /// Item that has been removed from the collection
+ /// Index the item has been removed from
+ protected virtual void OnRemoved(TItem item, int index) {
+ if(ItemRemoved != null) {
+ ItemRemoved(this, new ItemEventArgs(item));
+ }
+#if !NO_SPECIALIZED_COLLECTIONS
+ if(CollectionChanged != null) {
+ CollectionChanged(
+ this,
+ new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, index)
+ );
+ }
+#endif
+ }
+
+ /// Fires the 'ItemReplaced' event
+ /// Item that has been replaced
+ /// New item the original item was replaced with
+ /// Index of the replaced item
+ protected virtual void OnReplaced(TItem oldItem, TItem newItem, int index) {
+ if(ItemReplaced != null) {
+ ItemReplaced(this, new ItemReplaceEventArgs(oldItem, newItem));
+ }
+#if !NO_SPECIALIZED_COLLECTIONS
+ if(CollectionChanged != null) {
+ CollectionChanged(
+ this,
+ new NotifyCollectionChangedEventArgs(
+ NotifyCollectionChangedAction.Replace, newItem, oldItem, index
+ )
+ );
+ }
+#endif
+ }
+
+ /// Fires the 'Clearing' event
+ protected virtual void OnClearing() {
+ if(Clearing != null) {
+ Clearing(this, EventArgs.Empty);
+ }
+ }
+
+ /// Fires the 'Cleared' event
+ protected virtual void OnCleared() {
+ if(Cleared != null) {
+ Cleared(this, EventArgs.Empty);
+ }
+#if !NO_SPECIALIZED_COLLECTIONS
+ if(CollectionChanged != null) {
+ CollectionChanged(this, Constants.NotifyCollectionResetEventArgs);
+ }
+#endif
+ }
+
+ /// Counts the total number of items in the virtual collection
+ /// The total number of items
+ protected abstract int CountItems();
+
+ /// Fetches a page required by the collection
+ /// List into which the items should be fetched
+ ///
+ /// Index of the first item to fetch. This is both the start index in
+ /// the actual data and the element index at which to write into the list.
+ ///
+ /// Number of items that should be fetched
+ /// The number of items that were actually fetched
+ ///
+ /// If you fetch fewer than the requested number of items here, you will immediately
+ /// truncate the entire list (it will assume that the end was reached, a means
+ /// to support cases where the total number is not known). Fetching more than
+ /// the requested number of items will just put items in memory that the list will
+ /// continue to think are empty and fetch again if they are actually accessed.
+ ///
+ protected abstract Task FetchItemsAsync(
+ IList target, int startIndex, int count
+ );
+
+ /// Requests placeholder items to be created
+ /// List into which the items should be created
+ ///
+ /// Index of the first item to create a placeholder for. This is both the start index
+ /// in the actual data and the element index at which to write into the list.
+ ///
+ /// Number of items that should be crated
+ ///
+ /// When you requests items that have not been fetched yet, this method will be
+ /// called synchronously to create placeholder items (these could be empty items
+ /// or items with a 'StillLoading' flag set. This allows any UI controls showing
+ /// the collection's contents to immediately display the items while fetching
+ /// happens in the background and will replace the placeholder once complete.
+ ///
+ protected abstract void CreatePlaceholderItems(
+ IList target, int startIndex, int count
+ );
+
+ /// Ensures that the total number of items is known
+ private void requireCount() {
+ if(!this.assumedCount.HasValue) {
+ int itemCount = CountItems();
+ this.assumedCount = itemCount;
+
+ int pageCount = (itemCount + this.pageSize - 1) / this.pageSize;
+ this.fetchedPages = new bool[pageCount];
+ }
+ }
+
+ /// Ensures that all items have fetched
+ ///
+ /// Avoid if possible.
+ ///
+ private void requireAllPages() {
+ Debug.Assert(
+ this.assumedCount.HasValue,
+ "This method should only be called when item count is already known"
+ );
+ int pageCount = this.fetchedPages.Length;
+ for(int index = 0; index < pageCount; ++index) {
+ requirePage(index);
+ // CHECK: Should we throttle this by constructing a clever chain of
+ // ContinueWith() tasks so we don't cause a flood of async requests?
+ }
+ }
+
+ /// Ensures that the specified page has been fetched
+ /// Index of the page that needs to be fetched
+ private async void requirePage(int pageIndex) {
+ Debug.Assert(
+ this.assumedCount.HasValue,
+ "This method should only be called when item count is already known"
+ );
+ if(this.fetchedPages[pageIndex]) {
+ return;
+ }
+
+ int startIndex = pageIndex * this.pageSize;
+ int count = Math.Min(this.assumedCount.Value - startIndex, this.pageSize);
+ CreatePlaceholderItems(this.typedList, pageIndex * this.pageSize, count);
+
+ this.fetchedPages[pageIndex] = true; // Prevent double-fetch
+
+ // Send out change notifications that the items have been replaced
+ // (at this point, they're only placeholder items, of course)
+ var placeholderItems = new List(count);
+ for(int index = startIndex; index < count; ++index) {
+ placeholderItems[index - startIndex] = this.typedList[index];
+ OnReplaced(default(TItem), this.typedList[index], index);
+ }
+
+ int fetchedItemCount = await FetchItemsAsync(this.typedList, startIndex, count);
+ if(fetchedItemCount < this.pageSize) {
+ this.assumedCount = startIndex + fetchedItemCount;
+ }
+
+ // The count may have been adjusted if this truncated the list,
+ // so recalculate the actual number of items. Then send out change
+ // notifications for the items that have now been fetched.
+ count = Math.Min(this.assumedCount.Value - startIndex, this.pageSize);
+ for(int index = startIndex; index < count; ++index) {
+ OnReplaced(placeholderItems[index - startIndex], this.typedList[index], index);
+ }
+ }
+
+ /// Number of items the collection believes it has
+ private int? assumedCount;
+ /// Number of items to fetch in a single request
+ private readonly int pageSize;
+ /// Tracks which pages have been fetched so far
+ private bool[] fetchedPages;
+ /// The wrapped list under its type-safe interface
+ private IList typedList;
+ /// The wrapped list under its object interface
+ private IList objectList;
+#if DEBUG
+ /// Used to detect when enumerators go out of sync
+ private int version;
+#endif
+
+ }
+
+} // namespace Nuclex.Support.Collections