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
12 changes: 12 additions & 0 deletions .github/workflows/nuget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ jobs:
- name: Stellar.SourceGenerators
project: Stellar.SourceGenerators/Stellar.SourceGenerators.csproj
output: Stellar.SourceGenerators/bin/Release
# The Windows-targeted projects cross-compile on the macOS runner via
# EnableWindowsTargeting; Stellar.WinUI additionally disables the PRI/MSIX
# tooling on non-Windows hosts in its csproj.
- name: Stellar.Wpf
project: Stellar.Wpf/Stellar.Wpf.csproj
output: Stellar.Wpf/bin/Release
- name: Stellar.WinUI
project: Stellar.WinUI/Stellar.WinUI.csproj
output: Stellar.WinUI/bin/Release
- name: Stellar.Uno
project: Stellar.Uno/Stellar.Uno.csproj
output: Stellar.Uno/bin/Release
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
Expand Down
55 changes: 35 additions & 20 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,46 @@
<PackageVersion Include="FluentValidation" Version="12.1.1" />
</ItemGroup>

<!-- ReactiveUI. 21.x publishes no net10.0 target frameworks, so targeting
.NET 10 requires 23.x. This jump is forced by the framework rather
than chosen. -->
<!-- ReactiveUI 24 re-platformed onto ReactiveUI.Primitives and split into two
distributions. Stellar uses the .Reactive family (ReactiveUI.Reactive,
ReactiveUI.Maui.Reactive, ReactiveUI.Blazor.Reactive): it runs on the same
new Primitives engine as the default packages, but keeps System.Reactive 7
types (Unit, IScheduler, Subject<T>) at the API boundary, which Stellar's
public API is built on. System.Reactive is referenced directly because the
default ReactiveUI packages no longer pull it in transitively. -->
<ItemGroup>
<PackageVersion Include="ReactiveUI" Version="23.2.28" />
<PackageVersion Include="ReactiveUI.Blazor" Version="23.2.28" />
<PackageVersion Include="ReactiveUI.Maui" Version="23.2.28" />
<PackageVersion Include="ReactiveUI.Reactive" Version="24.0.0" />
<PackageVersion Include="ReactiveUI.Blazor.Reactive" Version="24.0.0" />
<PackageVersion Include="ReactiveUI.Maui.Reactive" Version="24.0.0" />
<PackageVersion Include="ReactiveUI.WPF.Reactive" Version="24.0.0" />
<PackageVersion Include="ReactiveUI.WinUI.Reactive" Version="24.0.0" />
<PackageVersion Include="System.Reactive" Version="7.0.0" />
<PackageVersion Include="ReactiveGenerator" Version="0.12.0" />
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.1.0" />
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
<PackageVersion Include="ReactiveMarbles.ObservableEvents.SourceGenerator" Version="1.3.1" />
</ItemGroup>

<!-- MAUI -->
<ItemGroup>
<PackageVersion Include="Microsoft.Maui.Controls" Version="10.0.90" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.90" />
<!-- CommunityToolkit.Maui 12.x pins Microsoft.Maui.Essentials below 10.0.0,
so .NET 10 forces 14.x here as well. -->
<PackageVersion Include="CommunityToolkit.Maui" Version="14.2.2" />
<PackageVersion Include="CommunityToolkit.Maui.Markup" Version="7.0.1" />
<PackageVersion Include="CommunityToolkit.Maui" Version="15.0.0" />
<PackageVersion Include="CommunityToolkit.Maui.Markup" Version="8.0.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Mopups" Version="1.3.4" />
</ItemGroup>

<!-- Avalonia is capped at 11.3.x: Avalonia itself has shipped 12.1.0, but
Avalonia.ReactiveUI has no 12.x release (not even a prerelease), and
Stellar.Avalonia depends on it. Revisit when 12.x lands there. -->
<!-- Avalonia 12. The former Avalonia.ReactiveUI dependency is gone: it has no
release compatible with ReactiveUI 24 (or Avalonia 12), so Stellar.Avalonia
now provides its own IViewFor base classes, activation fetcher and UI-thread
scheduler. Avalonia.Diagnostics was discontinued after 11.3 (DevTools moved
to the standalone Avalonia Accelerate tooling) and was referenced but unused,
so it is dropped rather than pinned. -->
<ItemGroup>
<PackageVersion Include="Avalonia" Version="11.3.18" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.18" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.9" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
<PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
</ItemGroup>

<!-- Blazor -->
Expand All @@ -62,6 +68,15 @@
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.10" />
</ItemGroup>

<!-- Windows (WPF / WinUI 3) and Uno. There is no ReactiveUI 24-compatible Uno
package (ReactiveUI.Uno stopped at 23, ReactiveUI.Uno.WinUI at 20), so
Stellar.Uno hand-rolls its activation fetcher and scheduler the same way
Stellar.Avalonia does. -->
<ItemGroup>
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.3.1" />
<PackageVersion Include="Uno.WinUI" Version="6.6.176" />
</ItemGroup>

<!-- Source generators. Roslyn is pinned to 5.0.0 rather than the newest
5.6.0 on purpose: a generator only loads in a host whose Roslyn is at
least the version it references, and the .NET 10 SDK feature bands ship
Expand Down
58 changes: 58 additions & 0 deletions Stellar.Avalonia/AvaloniaActivationForViewFetcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Reactive.Linq;
using Avalonia;
using Avalonia.Controls;
using ReactiveUI;

namespace Stellar.Avalonia;

/// <summary>
/// Determines when Avalonia views are activated and deactivated so that
/// <c>WhenActivated</c> works for them. Replaces Avalonia.ReactiveUI's
/// AvaloniaActivationForViewFetcher, which has no release compatible with
/// ReactiveUI 24. Windows activate on <see cref="Window.Opened"/> and deactivate on
/// <see cref="Window.Closed"/>; other controls follow visual-tree attachment.
/// </summary>
public sealed class AvaloniaActivationForViewFetcher : IActivationForViewFetcher
{
public int GetAffinityForView(Type view) =>
typeof(Visual).IsAssignableFrom(view) ? 10 : 0;

public IObservable<bool> GetActivationForView(IActivatableView view)
{
if (view is Window window)
{
var opened = Observable
.FromEventPattern<EventHandler, EventArgs>(
handler => window.Opened += handler,
handler => window.Opened -= handler)
.Select(static _ => true);

var closed = Observable
.FromEventPattern<EventHandler, EventArgs>(
handler => window.Closed += handler,
handler => window.Closed -= handler)
.Select(static _ => false);

return opened.Merge(closed).DistinctUntilChanged();
}

if (view is Control control)
{
var attached = Observable
.FromEventPattern<EventHandler<VisualTreeAttachmentEventArgs>, VisualTreeAttachmentEventArgs>(
handler => control.AttachedToVisualTree += handler,
handler => control.AttachedToVisualTree -= handler)
.Select(static _ => true);

var detached = Observable
.FromEventPattern<EventHandler<VisualTreeAttachmentEventArgs>, VisualTreeAttachmentEventArgs>(
handler => control.DetachedFromVisualTree += handler,
handler => control.DetachedFromVisualTree -= handler)
.Select(static _ => false);

return attached.Merge(detached).DistinctUntilChanged();
}

return Observable.Return(false);
}
}
75 changes: 75 additions & 0 deletions Stellar.Avalonia/AvaloniaScheduler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using Avalonia.Threading;

namespace Stellar.Avalonia;

/// <summary>
/// An <see cref="IScheduler"/> that schedules work onto the Avalonia UI thread.
/// Replaces Avalonia.ReactiveUI's AvaloniaScheduler, which has no release compatible
/// with ReactiveUI 24. Work scheduled with no due time from the UI thread runs inline.
/// </summary>
public sealed class AvaloniaScheduler : IScheduler
{
private AvaloniaScheduler()
{
}

public static AvaloniaScheduler Instance { get; } = new();

public DateTimeOffset Now => DateTimeOffset.Now;

public IDisposable Schedule<TState>(TState state, Func<IScheduler, TState, IDisposable> action)
{
var innerDisp = new SingleAssignmentDisposable();

if (Dispatcher.UIThread.CheckAccess())
{
innerDisp.Disposable = action(this, state);

return innerDisp;
}

Dispatcher.UIThread.Post(
() =>
{
if (!innerDisp.IsDisposed)
{
innerDisp.Disposable = action(this, state);
}
});

return innerDisp;
}

public IDisposable Schedule<TState>(TState state, TimeSpan dueTime, Func<IScheduler, TState, IDisposable> action)
{
if (dueTime <= TimeSpan.Zero)
{
return Schedule(state, action);
}

var innerDisp = new SingleAssignmentDisposable();

var timer = DispatcherTimer.RunOnce(
() =>
{
if (!innerDisp.IsDisposed)
{
innerDisp.Disposable = action(this, state);
}
},
dueTime);

return new CompositeDisposable(timer, innerDisp);
}

public IDisposable Schedule<TState>(TState state, DateTimeOffset dueTime, Func<IScheduler, TState, IDisposable> action)
{
var relative = dueTime - Now;

return relative <= TimeSpan.Zero
? Schedule(state, action)
: Schedule(state, relative, action);
}
}
24 changes: 9 additions & 15 deletions Stellar.Avalonia/Extensions/AppBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using System.Reflection;
using Avalonia;
using Avalonia.ReactiveUI;
using Microsoft.Extensions.DependencyInjection;
using ReactiveUI;
using ReactiveUI.Builder;
using ReactiveUI.Reactive.Builder;
using Splat;

namespace Stellar.Avalonia;
Expand All @@ -12,24 +11,19 @@ public static class AppBuilderExtensions
{
public static AppBuilder UseStellarComponents(this AppBuilder appBuilder)
{
// Avalonia.ReactiveUI 11.3.9 was built against ReactiveUI 20 and its
// bootstrap types -- Registrations and AvaloniaMixins.UseReactiveUI --
// reference RxApp, PlatformRegistrationManager and Splat's IEnableLogger,
// all removed in ReactiveUI 23 / Splat 19. Those types now fail to load,
// so Stellar performs the registrations itself. The pieces it needs
// (AvaloniaActivationForViewFetcher, AvaloniaScheduler,
// AutoDataTemplateBindingHook, and the ReactiveWindow/ReactiveUserControl
// base classes) all load and work against ReactiveUI 23.
// Avalonia.ReactiveUI has no release compatible with ReactiveUI 24 (or with
// Avalonia 12), so Stellar no longer references it and provides the pieces it
// needs itself: Stellar's own AvaloniaActivationForViewFetcher and
// AvaloniaScheduler here, and the IViewFor implementations in
// WindowBase/UserControlBase. Avalonia.ReactiveUI's AutoDataTemplateBindingHook
// is intentionally not re-implemented: it only supplied automatic data
// templates for ViewModelViewHost, which Stellar's view management never uses.
Locator.CurrentMutable.InitializeSplat();

RxAppBuilder
.CreateReactiveUIBuilder()
.WithRegistration(
static resolver =>
{
resolver.RegisterConstant<IActivationForViewFetcher>(new AvaloniaActivationForViewFetcher());
resolver.RegisterConstant<IPropertyBindingHook>(new AutoDataTemplateBindingHook());
})
static resolver => resolver.RegisterConstant<IActivationForViewFetcher>(new AvaloniaActivationForViewFetcher()))
.WithMainThreadScheduler(AvaloniaScheduler.Instance)
.WithTaskPoolScheduler(Schedulers.ShortTermThreadPoolScheduler)
.Build();
Expand Down
3 changes: 0 additions & 3 deletions Stellar.Avalonia/Stellar.Avalonia.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" />
<PackageReference Include="Avalonia.ReactiveUI" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.AutoActivation" />
</ItemGroup>
<ItemGroup>
Expand Down
62 changes: 58 additions & 4 deletions Stellar.Avalonia/UserControlBase.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
using System.ComponentModel;
using System.ComponentModel;
using System.Reactive;
using System.Reactive.Disposables;
using Avalonia;
using Avalonia.ReactiveUI;
using Avalonia.Controls;
using ReactiveUI;

namespace Stellar.Avalonia;

public abstract class UserControlBase<TViewModel> : ReactiveUserControl<TViewModel>, IStellarView<TViewModel>
// Derives from UserControl directly and implements IViewFor itself: this base class
// previously came from Avalonia.ReactiveUI's ReactiveUserControl, which has no release
// compatible with ReactiveUI 24.
public abstract class UserControlBase<TViewModel> : UserControl, IStellarView<TViewModel>
where TViewModel : class
{
public static readonly StyledProperty<TViewModel?> ViewModelProperty =
AvaloniaProperty.Register<UserControlBase<TViewModel>, TViewModel?>(nameof(ViewModel));

Check warning on line 17 in Stellar.Avalonia/UserControlBase.cs

View workflow job for this annotation

GitHub Actions / Core (build + test)

Inadvisable registration: Generic types cannot be referenced from XAML. Create a non-generic type to be the owner of this AvaloniaProperty.

Check warning on line 17 in Stellar.Avalonia/UserControlBase.cs

View workflow job for this annotation

GitHub Actions / Core (build + test)

Inadvisable registration: Generic types cannot be referenced from XAML. Create a non-generic type to be the owner of this AvaloniaProperty.

[EditorBrowsable(EditorBrowsableState.Never)]
public ViewManager<TViewModel> ViewManager { get; } = new AvaloniaViewManager<TViewModel>();

public TViewModel? ViewModel
{
get => GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}

object? IViewFor.ViewModel
{
get => ViewModel;
set => ViewModel = value as TViewModel;
}

public IObservable<Unit> UserControlInitialized => ViewManager.Initialized;

public IObservable<Unit> Activated => ViewManager.Activated;
Expand Down Expand Up @@ -65,20 +84,52 @@
base.OnDetachedFromVisualTree(e);
}

protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);

ViewModel = DataContext as TViewModel;
}

protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property == ViewModelProperty)
{
var newViewModel = change.GetNewValue<TViewModel?>();

if (!ReferenceEquals(newViewModel, DataContext))
{
DataContext = newViewModel;
}
}

ViewManager.PropertyChanged(this, change.Property.Name);

base.OnPropertyChanged(change);
}
}

public abstract class UserControlBase<TViewModel, TDataModel> : ReactiveUserControl<TViewModel>, IStellarView<TViewModel>
public abstract class UserControlBase<TViewModel, TDataModel> : UserControl, IStellarView<TViewModel>
where TViewModel : class
{
public static readonly StyledProperty<TViewModel?> ViewModelProperty =
AvaloniaProperty.Register<UserControlBase<TViewModel, TDataModel>, TViewModel?>(nameof(ViewModel));

Check warning on line 116 in Stellar.Avalonia/UserControlBase.cs

View workflow job for this annotation

GitHub Actions / Core (build + test)

Inadvisable registration: Generic types cannot be referenced from XAML. Create a non-generic type to be the owner of this AvaloniaProperty.

Check warning on line 116 in Stellar.Avalonia/UserControlBase.cs

View workflow job for this annotation

GitHub Actions / Core (build + test)

Inadvisable registration: Generic types cannot be referenced from XAML. Create a non-generic type to be the owner of this AvaloniaProperty.

[EditorBrowsable(EditorBrowsableState.Never)]
public ViewManager<TViewModel> ViewManager { get; } = new AvaloniaViewManager<TViewModel>();

public TViewModel? ViewModel
{
get => GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}

object? IViewFor.ViewModel
{
get => ViewModel;
set => ViewModel = value as TViewModel;
}

public IObservable<Unit> UserControlInitialized => ViewManager.Initialized;

public IObservable<Unit> Activated => ViewManager.Activated;
Expand Down Expand Up @@ -117,6 +168,9 @@
base.OnDetachedFromVisualTree(e);
}

// No ViewModel<->DataContext syncing here: unlike the single-parameter base class,
// DataContext holds a TDataModel that is mapped onto the view model, so the two
// properties are intentionally independent.
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
ViewManager.PropertyChanged(this, change.Property.Name);
Expand Down
Loading
Loading