Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,19 @@ This document provides guidance for AI agents and contributors working on the **

MvvmControls is a WPF MVVM helper library that consolidates boiler-plate ViewModel interactions for common WPF controls into pre-built classes. It ships alongside a set of Roslyn source generators that reduce the amount of code a consumer needs to write.

The solution (`RFBCodeWorks.Mvvm.sln`) is composed of the following projects:

| Project | Path | Status |
The solution (`RFBCodeWorks.Mvvm.sln`) is organized into the following logical components:
| Component | Path / Projects | Status |
|---|---|---|
| **Mvvm.Controls** | `src/Mvvm.Controls/` | Primary library – active development |
| **Mvvm.Controls.SourceGenerators** | `src/Mvvm.Controls.SourceGenerators/` | Source generators – active development |
| **Source generators** | `src/SourceGenerators.Roslyn311/`, `src/SourceGenerators.Roslyn410/`, `src/SourceGenerators.Roslyn500/` | Roslyn-version-specific generator projects – active development |
| Mvvm.IViewModel | `src/Mvvm.IViewModel/` | Interface-only library – largely complete |
| Mvvm.Dialogs | `src/Mvvm.Dialogs/` | Dialog helpers – largely complete |
| Mvvm.WebView2Integration | `src/Mvvm.WebView2Integration/` | WebView2 helpers – largely complete |
| ExampleWPF | `src/ExampleWPF/` | Demo application – reference only |
| MvvmControlsTests | `tests/MvvmControlsTests/` | Test project |

> **Focus your work on `Mvvm.Controls` and `Mvvm.Controls.SourceGenerators`**. The other libraries are considered feature-complete and should not need significant changes.
| TestWebView2 | `src/TestWebView2/` | WebView2 sample/test application |
| Mvvm.Controls.Tests | `tests/Mvvm.Controls.Tests/` | Test project |

> **Focus your work on `Mvvm.Controls` and the `SourceGenerators.Roslyn311/410/500` projects**. The other libraries are considered feature-complete and should not need significant changes.
---

## Build & Test
Expand Down Expand Up @@ -107,7 +106,7 @@ The source generator code is compiled three times against different Roslyn SDK v
|---|---|---|
| `SourceGenerators.Roslyn311.csproj` | 3.11 | VS2019, .NET Framework / older toolchains |
| `SourceGenerators.Roslyn410.csproj` | 4.10 | VS2022 (current) |
| `SourceGenerators.Roslyn500.csproj` | 5.0 | VS2026 / .NET 10 toolchain |
| `SourceGenerators.Roslyn500.csproj` | 5.x | VS2026 / .NET 10 toolchain |

All three projects share the same source files under `src/`. Version-specific files use the naming convention `*Roslyn311.cs` / `*Roslyn3*.cs` (excluded from Roslyn4+ builds).

Expand Down
1 change: 1 addition & 0 deletions RFBCodeWorks.Mvvm.sln
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".Solution Items", ".Solution Items", "{F7F23A78-3D3B-40F2-9FC0-B4B70B15378A}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
AGENTS.md = AGENTS.md
.github\workflows\build-and-test.yml = .github\workflows\build-and-test.yml
MvvmControls.snk = MvvmControls.snk
README.md = README.md
Expand Down
6 changes: 6 additions & 0 deletions src/Mvvm.Controls/CSharp9_MissingComponents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ internal static class IsExternalInit

namespace System.Diagnostics.CodeAnalysis
{
[AttributeUsage(validOn: AttributeTargets.Parameter)]
internal class NotNullAttribute : Attribute { }

[AttributeUsage(validOn: AttributeTargets.Method)]
internal class DoesNotReturnAttribute : Attribute { }

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = true)]
internal sealed class MemberNotNullWhenAttribute(bool returnValue, params string[] members) : Attribute
{
Expand Down
12 changes: 10 additions & 2 deletions src/Mvvm.Controls/Mvvm/ExtensionMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Diagnostics.CodeAnalysis;

#nullable enable

Expand All @@ -10,11 +11,18 @@ namespace RFBCodeWorks.Mvvm

internal static class ExtensionMethods
{
public static void ThrowIfNull<T>(this T value, string paramName)
public static void ThrowIfNull<T>([NotNull] this T value, string paramName)
{
if (value is null) ThrowArgNull(paramName);
}
private static void ThrowArgNull(string? paramName) => throw new ArgumentNullException(paramName);

public static void ThrowInvalidOperationIfNull<T>([NotNull] this T value, string message)
{
if (value is null) ThrowInvalidOperation(message);
}

[DoesNotReturn] private static void ThrowArgNull(string? paramName) => throw new ArgumentNullException(paramName);
[DoesNotReturn] private static void ThrowInvalidOperation(string message) => throw new InvalidOperationException(message);


/// <summary>
Expand Down
32 changes: 26 additions & 6 deletions src/Mvvm.Controls/Mvvm/IRefreshableItemSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,31 +35,51 @@ public interface IRefreshableItemSource : IItemSource
/// <summary>
/// A RelayCommand that can be used to refresh the collection
/// </summary>
public IRelayCommand RefreshCommand { get; }
IRelayCommand RefreshCommand { get; }

/// <summary>
/// Gets a command that can be used to cancel the refresh if a cancellable refresh method was supplied to the constructor.
/// </summary>
public System.Windows.Input.ICommand CancelRefreshCommand { get; }
System.Windows.Input.ICommand CancelRefreshCommand { get; }

/// <summary>
/// Update the ItemSource
/// </summary>
public void Refresh();
void Refresh();

/// <summary>
/// Update the ItemSource asynchronously
/// </summary>
public Task RefreshAsync(CancellationToken token);
Task RefreshAsync(CancellationToken token);

/// <summary>
/// Checks if the <see cref="IItemSource.Items"/> collection has been initialized.
/// Will only trigger a refresh if the collection has not been initialized yet.
/// </summary>
/// <param name="maxWaitTime">
/// Maximum time to wait for an asynchronous refresh to complete.
/// If not specified, defaults to 3 seconds.
/// </param>
/// <exception cref="RefreshFailedException"/>
/// <exception cref="OperationCanceledException"/>
void EnsureInitialized(TimeSpan? maxWaitTime = null);

/// <summary>
/// Checks if the <see cref="IItemSource.Items"/> collection has been initialized.
/// Will only trigger a refresh if the collection has not been initialized yet.
/// </summary>
/// <exception cref="RefreshFailedException"/>
/// <exception cref="OperationCanceledException"/>
Task EnsureInitializedAsync(CancellationToken token);

/// <summary>
/// Public EventHandler method to allow triggering the refresh via another object's event
/// </summary>
public void Refresh(object? sender, EventArgs e);
void Refresh(object? sender, EventArgs e);

/// <summary>
/// Public EventHandler to allow triggering the refresh via a routed event
/// </summary>
public void Refresh(object? sender, RoutedEventArgs e);
void Refresh(object? sender, RoutedEventArgs e);
}
}
6 changes: 3 additions & 3 deletions src/Mvvm.Controls/Mvvm/Primitives/ItemSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,11 @@ public virtual TList Items
OnItemSourceChanged(EventArgs.Empty);
OnPropertyChanged(EventArgSingletons.Items);

// Invoke the optional action passed to the constructor
_onCollectionChanged?.Invoke();

// invoke derived class requirements
OnItemsChanged();

// Invoke the optional action passed to the constructor
_onCollectionChanged?.Invoke();
}
}
}
Expand Down
41 changes: 27 additions & 14 deletions src/Mvvm.Controls/Mvvm/Primitives/SelectorDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public SelectorDefinition(Action? onCollectionChanged = null, Action? onSelectio
private int? _selectedIndex = null;
private bool _indexChanging;
private bool _selectedValueChanging;
private bool _collectionChanging = false;

/// <summary>
/// Occurs after the <see cref="SelectedItem"/> has been updated
Expand Down Expand Up @@ -116,11 +117,6 @@ public TSelectedValue SelectedValue
}
}

///// <summary>
///// Enable/Disable auto-updating of item and SelectedIndex if bound by the ItemSource binding definition attached property
///// </summary>
//internal bool IsBoundByBehavior { get; set; }

/// <summary>
/// The index of the currently selected item within the ItemSource.
/// </summary>
Expand All @@ -141,14 +137,15 @@ static int getIndex(T? selection, TList list)
}
set
{
if (_selectedIndex == value)

if (!_collectionChanging && _selectedIndex == value)
{
return; // no change;
}
else if (value >= 0 && (Items?.Count ?? 0) == 0) // deselect the item
else if (value >= 0 && (Items?.Count ?? 0) == 0) // throw if greater than collection count
{
// No items in collection
throw new ArgumentOutOfRangeException($"SelectedIndex property can not be set when the collection has no items", nameof(SelectedIndex));
throw new ArgumentOutOfRangeException(nameof(value), $"SelectedIndex property can not be set when the collection has no items");
}
else if (value < Items.Count)
{
Expand Down Expand Up @@ -178,32 +175,47 @@ static int getIndex(T? selection, TList list)
RaiseSelectedValueChanged(oldSelectedValue);
}
_indexChanging = false;
_collectionChanging = false;
}
else
{
throw new ArgumentOutOfRangeException($"SelectedIndex property was set to a value outside the valid range (expected value between -1 and number of items in the collection ( currently: {Items.Count} )", nameof(SelectedIndex));
throw new ArgumentOutOfRangeException(nameof(value), $"SelectedIndex property was set to a value outside the valid range (expected value between -1 and number of items in the collection ( currently: {Items.Count} )");
}
}
}

/// <inheritdoc/>
/// <summary>
/// Flag that the collection is changing,
/// so that the SelectedIndex and SelectedItem can be updated accordingly in <see cref="OnItemsChanged"/>
/// </summary>
protected override void OnItemsChanging()
{
base.OnItemsChanging();
_collectionChanging = true;
}

/// <inheritdoc/>

/// <summary>
/// Sets the SelectedIndex to -1 if the collection has changed and the SelectedIndex or SelectedItem has not been updated during that collection change.
/// Also sets the SelectedIndex to -1 if the collection is cleared ( count == 0 ) or if the SelectedItem is null.
/// </summary>
protected override void OnItemsChanged()
{
SelectedIndex = -1;
base.OnItemsChanged();
if (_collectionChanging || SelectedItem is null || Items.Count == 0)
{
SelectedIndex = -1;
}
_collectionChanging = false;
}

/// <summary>
/// <see cref="SelectedItem"/> is Changing
/// </summary>
partial void OnSelectedItemChanging(T value)
{
// reset the flag that would reset the SelectedIndex to -1
// this accommodates scenarios where OnCollectionChanged is used to select an item from the new collection.
_collectionChanging = false;

if (_indexChanging || _selectedValueChanging) return; // changing event raised in index setter
OnPropertyChanging(EventArgSingletons.SelectedIndex);
OnPropertyChanging(EventArgSingletons.SelectedValue);
Expand Down Expand Up @@ -231,6 +243,7 @@ partial void OnSelectedItemChanged(T? oldValue, T newValue)
OnPropertyChanged(EventArgSingletons.SelectedValue);
RaiseSelectedValueChanged(oldSelectedValue);
}

// invoke the constructor supplied action
_onSelectionChanged?.Invoke();
}
Expand Down
88 changes: 69 additions & 19 deletions src/Mvvm.Controls/Mvvm/RefreshableSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,58 @@ private set
}
}

private static async Task WaitForTaskOrCancellationAsync(Task task, CancellationToken token)
{
if (task.Status < TaskStatus.RanToCompletion)
{
var tcs = new TaskCompletionSource<object>();
using var reg = token.Register(() => tcs.SetCanceled());
var completedTask = await Task.WhenAny(tcs.Task, task); // returns when cancellation is requested or the refresh task completes
if (completedTask == tcs.Task)
{
token.ThrowIfCancellationRequested();
}
}
}

private async Task AwaitLatestExecutionTaskAsync(IAsyncRelayCommand asyncCommand, CancellationToken token)
{
while (asyncCommand.ExecutionTask is Task task)
{
await WaitForTaskOrCancellationAsync(task, token);

try
{
await task;
return;
}
catch (OperationCanceledException) when (!token.IsCancellationRequested)
{
var currentTask = asyncCommand.ExecutionTask;
if (ReferenceEquals(currentTask, task) && !asyncCommand.IsRunning && !IsRefreshing)
{
await asyncCommand.ExecuteAsync(token);
return;
}

// Another refresh has superseded this one (or is still in-flight).
// Continue waiting for the latest execution task.
}
}
}

private async Task RefreshAsyncInternal(IAsyncRelayCommand asyncCmd, CancellationToken token)
{
using var cancellationReg = token.Register(asyncCmd.Cancel);
if (asyncCmd.IsRunning)
{
await AwaitLatestExecutionTaskAsync(asyncCmd, token);
}
else
{
await asyncCmd.ExecuteAsync(token);
}
}
/// <inheritdoc/>
public IRelayCommand RefreshCommand => _refreshCommand ??= CreateCommand();

Expand All @@ -149,7 +201,7 @@ private IRelayCommand CreateCommand()
}
else if (_cancellableRefreshAsync is not null)
{
return new AsyncRelayCommand(RefreshTask, _canRefresh, AsyncRelayCommandOptions.None);
return new AsyncRelayCommand(RefreshTask, _canRefresh, AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler);
}
return InactiveButton.Instance;
}
Expand Down Expand Up @@ -331,18 +383,7 @@ public async Task EnsureInitializedAsync(CancellationToken token)
}
else if (RefreshCommand is IAsyncRelayCommand asyncCommand && asyncCommand.ExecutionTask is not null)
{
var task = asyncCommand.ExecutionTask;
if (task.Status < TaskStatus.RanToCompletion)
{
var tcs = new TaskCompletionSource<object>();
using var reg = token.Register(() => tcs.SetCanceled());
var completedTask = await Task.WhenAny(tcs.Task, task); // returns when cancellation is requested or the refresh task completes
if (completedTask == tcs.Task)
{
token.ThrowIfCancellationRequested();
}
}
await task;
await AwaitLatestExecutionTaskAsync(asyncCommand, token);
}

else if (IsRefreshing)
Expand All @@ -354,6 +395,20 @@ public async Task EnsureInitializedAsync(CancellationToken token)
}
}
}
catch (OperationCanceledException) when (!token.IsCancellationRequested && RefreshCommand is IAsyncRelayCommand)
{
if (RefreshCommand is IAsyncRelayCommand asyncCommand)
{
try
{
await AwaitLatestExecutionTaskAsync(asyncCommand, token);
}
catch (OperationCanceledException) when (!token.IsCancellationRequested)
{
ResetInitializedState();
}
}
}
catch (OperationCanceledException) { ResetInitializedState(); throw; }
catch (RefreshFailedException) { ResetInitializedState(); throw; }
catch (Exception e)
Expand Down Expand Up @@ -400,12 +455,7 @@ public Task RefreshAsync(CancellationToken token)

if (RefreshCommand is IAsyncRelayCommand asyncCmd)
{
using var cancellationReg = token.Register(asyncCmd.Cancel);
if (asyncCmd.IsRunning)
{
return asyncCmd.ExecutionTask!;
}
return asyncCmd.ExecuteAsync(token);
return RefreshAsyncInternal(asyncCmd, token);
}
else if (_refresh is not null)
{
Expand Down
13 changes: 13 additions & 0 deletions tests/.Playlists/RefreshableSelectorTests.playlist
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Playlist Version="2.0">
<Rule Name="Includes" Match="Any">
<Rule Match="All">
<Property Name="Solution" />
<Rule Match="Any">
<Property Name="Class" Value="RefreshableSelectorTests" />
<Property Name="Class" Value="RefreshableSelectorSequenceTests" />
<Property Name="Class" Value="ComboBoxDefinitionTests" />
<Property Name="Class" Value="ListBoxDefinitionTests" />
</Rule>
</Rule>
</Rule>
</Playlist>
Loading