diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml index dfa7a67..f14ce38 100644 --- a/.github/workflows/nuget.yml +++ b/.github/workflows/nuget.yml @@ -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 diff --git a/Directory.Packages.props b/Directory.Packages.props index c27665d..fd52e21 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,15 +20,22 @@ - + - - - + + + + + + - + @@ -36,24 +43,23 @@ - - - + + - + - - - - - - + + + + @@ -62,6 +68,15 @@ + + + + + + - - diff --git a/Stellar.Avalonia/UserControlBase.cs b/Stellar.Avalonia/UserControlBase.cs index 3fb62e4..a0c9bcd 100644 --- a/Stellar.Avalonia/UserControlBase.cs +++ b/Stellar.Avalonia/UserControlBase.cs @@ -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 : ReactiveUserControl, IStellarView +// 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 : UserControl, IStellarView where TViewModel : class { + public static readonly StyledProperty ViewModelProperty = + AvaloniaProperty.Register, TViewModel?>(nameof(ViewModel)); + [EditorBrowsable(EditorBrowsableState.Never)] public ViewManager ViewManager { get; } = new AvaloniaViewManager(); + public TViewModel? ViewModel + { + get => GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = value as TViewModel; + } + public IObservable UserControlInitialized => ViewManager.Initialized; public IObservable Activated => ViewManager.Activated; @@ -65,20 +84,52 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e 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(); + + if (!ReferenceEquals(newViewModel, DataContext)) + { + DataContext = newViewModel; + } + } + ViewManager.PropertyChanged(this, change.Property.Name); base.OnPropertyChanged(change); } } -public abstract class UserControlBase : ReactiveUserControl, IStellarView +public abstract class UserControlBase : UserControl, IStellarView where TViewModel : class { + public static readonly StyledProperty ViewModelProperty = + AvaloniaProperty.Register, TViewModel?>(nameof(ViewModel)); + [EditorBrowsable(EditorBrowsableState.Never)] public ViewManager ViewManager { get; } = new AvaloniaViewManager(); + public TViewModel? ViewModel + { + get => GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = value as TViewModel; + } + public IObservable UserControlInitialized => ViewManager.Initialized; public IObservable Activated => ViewManager.Activated; @@ -117,6 +168,9 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e 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); diff --git a/Stellar.Avalonia/WindowBase.cs b/Stellar.Avalonia/WindowBase.cs index 454f4dc..2083ee7 100644 --- a/Stellar.Avalonia/WindowBase.cs +++ b/Stellar.Avalonia/WindowBase.cs @@ -4,17 +4,35 @@ using System.Reactive.Disposables; using Avalonia; using Avalonia.Controls; -using Avalonia.ReactiveUI; +using ReactiveUI; using Stellar.ViewModel; namespace Stellar.Avalonia { - public abstract class WindowBase : ReactiveWindow, IStellarView + // Derives from Window directly and implements IViewFor itself: this base class + // previously came from Avalonia.ReactiveUI's ReactiveWindow, which has no release + // compatible with ReactiveUI 24. + public abstract class WindowBase : Window, IStellarView where TViewModel : class { + public static readonly StyledProperty ViewModelProperty = + AvaloniaProperty.Register, TViewModel?>(nameof(ViewModel)); + [EditorBrowsable(EditorBrowsableState.Never)] public ViewManager ViewManager { get; } = new AvaloniaViewManager(); + public TViewModel? ViewModel + { + get => GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = value as TViewModel; + } + public IObservable WindowInitialized => ViewManager.Initialized; public IObservable WindowActivated => ViewManager.Activated; @@ -77,8 +95,25 @@ protected override void OnClosing(WindowClosingEventArgs e) base.OnClosing(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(); + + if (!ReferenceEquals(newViewModel, DataContext)) + { + DataContext = newViewModel; + } + } + ViewManager.PropertyChanged(this, change.Property.Name); base.OnPropertyChanged(change); diff --git a/Stellar.AvaloniaSample/GlobalUsings.cs b/Stellar.AvaloniaSample/GlobalUsings.cs index eea879e..ae68aa3 100644 --- a/Stellar.AvaloniaSample/GlobalUsings.cs +++ b/Stellar.AvaloniaSample/GlobalUsings.cs @@ -2,4 +2,5 @@ global using System.Reactive.Disposables; global using System.Reactive.Linq; global using ReactiveUI; +global using ReactiveUI.Reactive; global using ReactiveUI.SourceGenerators; diff --git a/Stellar.AvaloniaSample/Program.cs b/Stellar.AvaloniaSample/Program.cs index e2a04fa..8a643ff 100644 --- a/Stellar.AvaloniaSample/Program.cs +++ b/Stellar.AvaloniaSample/Program.cs @@ -1,6 +1,5 @@ using System; using Avalonia; -using Avalonia.ReactiveUI; using Stellar.Avalonia; namespace Stellar.AvaloniaSample; @@ -21,9 +20,8 @@ public static AppBuilder BuildAvaloniaApp() => .UsePlatformDetect() .WithInterFont() .LogToTrace() - .UseStellarComponents() #if DEBUG .EnableHotReload() #endif - .UseReactiveUI(); + .UseStellarComponents(); } diff --git a/Stellar.AvaloniaSample/Stellar.AvaloniaSample.csproj b/Stellar.AvaloniaSample/Stellar.AvaloniaSample.csproj index 6375a9c..f951fe5 100644 --- a/Stellar.AvaloniaSample/Stellar.AvaloniaSample.csproj +++ b/Stellar.AvaloniaSample/Stellar.AvaloniaSample.csproj @@ -18,9 +18,6 @@ - - - all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Stellar.Blazor/ComponentBase.cs b/Stellar.Blazor/ComponentBase.cs index 08fc344..c9de932 100644 --- a/Stellar.Blazor/ComponentBase.cs +++ b/Stellar.Blazor/ComponentBase.cs @@ -3,7 +3,7 @@ using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; -using ReactiveUI.Blazor; +using ReactiveUI.Reactive.Blazor; namespace Stellar.Blazor; diff --git a/Stellar.Blazor/Extensions/BuilderExtensions.cs b/Stellar.Blazor/Extensions/BuilderExtensions.cs index dea3654..92fe1c1 100644 --- a/Stellar.Blazor/Extensions/BuilderExtensions.cs +++ b/Stellar.Blazor/Extensions/BuilderExtensions.cs @@ -1,6 +1,6 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; -using ReactiveUI.Builder; +using ReactiveUI.Reactive.Builder; namespace Stellar.Blazor; diff --git a/Stellar.Blazor/GlobalUsings.cs b/Stellar.Blazor/GlobalUsings.cs index af4247b..ac002eb 100644 --- a/Stellar.Blazor/GlobalUsings.cs +++ b/Stellar.Blazor/GlobalUsings.cs @@ -5,4 +5,5 @@ global using System.Reactive.Linq; global using System.Reactive.Subjects; global using ReactiveUI; -global using ReactiveUI.Blazor; \ No newline at end of file +global using ReactiveUI.Reactive; +global using ReactiveUI.Reactive.Blazor; \ No newline at end of file diff --git a/Stellar.Blazor/InjectableComponentBase.cs b/Stellar.Blazor/InjectableComponentBase.cs index 8bb5ebe..d110f4a 100644 --- a/Stellar.Blazor/InjectableComponentBase.cs +++ b/Stellar.Blazor/InjectableComponentBase.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Components; -using ReactiveUI.Blazor; +using ReactiveUI.Reactive.Blazor; namespace Stellar.Blazor; diff --git a/Stellar.Blazor/LayoutComponentBase.cs b/Stellar.Blazor/LayoutComponentBase.cs index 8e7b5d0..43bc7a0 100644 --- a/Stellar.Blazor/LayoutComponentBase.cs +++ b/Stellar.Blazor/LayoutComponentBase.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Components; -using ReactiveUI.Blazor; +using ReactiveUI.Reactive.Blazor; namespace Stellar.Blazor; diff --git a/Stellar.Blazor/Stellar.Blazor.csproj b/Stellar.Blazor/Stellar.Blazor.csproj index 02b5be9..4117fa2 100644 --- a/Stellar.Blazor/Stellar.Blazor.csproj +++ b/Stellar.Blazor/Stellar.Blazor.csproj @@ -17,7 +17,7 @@ - + diff --git a/Stellar.BlazorSample/GlobalUsings.cs b/Stellar.BlazorSample/GlobalUsings.cs index 17682b3..cc3bcee 100644 --- a/Stellar.BlazorSample/GlobalUsings.cs +++ b/Stellar.BlazorSample/GlobalUsings.cs @@ -2,4 +2,5 @@ global using System.Reactive.Disposables; global using System.Reactive.Linq; global using ReactiveUI; +global using ReactiveUI.Reactive; global using ReactiveUI.SourceGenerators; diff --git a/Stellar.Maui.PopUp/Extensions/PopupNavigationObservableExtensions.cs b/Stellar.Maui.PopUp/Extensions/PopupNavigationObservableExtensions.cs index b2791c6..9b97453 100644 --- a/Stellar.Maui.PopUp/Extensions/PopupNavigationObservableExtensions.cs +++ b/Stellar.Maui.PopUp/Extensions/PopupNavigationObservableExtensions.cs @@ -4,6 +4,7 @@ using Mopups.Pages; using Mopups.Services; using ReactiveUI; +using ReactiveUI.Reactive; using Stellar.Exceptions; using Stellar.Maui.Exceptions; using static Stellar.Maui.NavigationObservableExtensions; diff --git a/Stellar.Maui.PopUp/ReactivePopupPage.cs b/Stellar.Maui.PopUp/ReactivePopupPage.cs index ca59490..4ac774e 100644 --- a/Stellar.Maui.PopUp/ReactivePopupPage.cs +++ b/Stellar.Maui.PopUp/ReactivePopupPage.cs @@ -1,5 +1,6 @@ using Mopups.Pages; using ReactiveUI; +using ReactiveUI.Reactive; namespace Stellar.Maui.Pages; diff --git a/Stellar.Maui.PopUp/Stellar.Maui.PopUp.csproj b/Stellar.Maui.PopUp/Stellar.Maui.PopUp.csproj index 7f831b4..c10aaf9 100644 --- a/Stellar.Maui.PopUp/Stellar.Maui.PopUp.csproj +++ b/Stellar.Maui.PopUp/Stellar.Maui.PopUp.csproj @@ -33,7 +33,7 @@ - + diff --git a/Stellar.Maui/Extensions/MauiAppBuilderExtensions.cs b/Stellar.Maui/Extensions/MauiAppBuilderExtensions.cs index 020c9f4..ac6edcf 100644 --- a/Stellar.Maui/Extensions/MauiAppBuilderExtensions.cs +++ b/Stellar.Maui/Extensions/MauiAppBuilderExtensions.cs @@ -1,7 +1,7 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection.Extensions; -using ReactiveUI.Builder; -using ReactiveUI.Maui; +using ReactiveUI.Reactive.Builder; +using ReactiveUI.Reactive.Maui; using Splat; namespace Stellar.Maui; diff --git a/Stellar.Maui/Extensions/VisualElementExtensions.cs b/Stellar.Maui/Extensions/VisualElementExtensions.cs index 6fe753f..57404cd 100644 --- a/Stellar.Maui/Extensions/VisualElementExtensions.cs +++ b/Stellar.Maui/Extensions/VisualElementExtensions.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using DynamicData.Diagnostics; using Stellar.Exceptions; using Stellar.ViewModel; diff --git a/Stellar.Maui/GlobalUsings.cs b/Stellar.Maui/GlobalUsings.cs index 3859fcc..da99508 100644 --- a/Stellar.Maui/GlobalUsings.cs +++ b/Stellar.Maui/GlobalUsings.cs @@ -10,4 +10,10 @@ global using System.Reactive.Linq; global using System.Reactive.Subjects; global using ReactiveUI; + +// Stellar's own MAUI view wrappers (ReactiveStackLayout, ReactiveGrid, ReactiveViewCell) +// live in the ReactiveUI.Maui namespaces for historical compatibility; the ReactiveUI 24 +// platform types now come from ReactiveUI.Reactive.Maui. global using ReactiveUI.Maui; +global using ReactiveUI.Reactive; +global using ReactiveUI.Reactive.Maui; diff --git a/Stellar.Maui/Stellar.Maui.csproj b/Stellar.Maui/Stellar.Maui.csproj index ff80ba7..634273d 100644 --- a/Stellar.Maui/Stellar.Maui.csproj +++ b/Stellar.Maui/Stellar.Maui.csproj @@ -33,7 +33,7 @@ - + diff --git a/Stellar.Maui/Views/ReactiveViewCell.cs b/Stellar.Maui/Views/ReactiveViewCell.cs new file mode 100644 index 0000000..3fa53b7 --- /dev/null +++ b/Stellar.Maui/Views/ReactiveViewCell.cs @@ -0,0 +1,59 @@ +// StellarUI continues to support ListView for as long as .NET MAUI ships it. MAUI marks +// ListView and its cell types obsolete in favour of CollectionView, but removing this +// surface would break every consumer still using it, so the deprecation is suppressed here +// rather than propagated. Revisit when MAUI actually removes the types. +#pragma warning disable CS0618 // Type or member is obsolete + +// ReactiveUI.Maui shipped a ReactiveViewCell up to version 23; ReactiveUI 24 dropped it, +// so Stellar provides its own. It lives in this namespace alongside ReactiveGrid and +// ReactiveStackLayout, which Stellar has always supplied itself. +namespace ReactiveUI.Maui.Views; + +/// +/// This is a that is also an . +/// +/// The type of the view model. +/// +/// +public class ReactiveViewCell : ViewCell, IViewFor + where TViewModel : class +{ + /// + /// The view model bindable property. + /// + public static readonly BindableProperty ViewModelProperty = BindableProperty.Create( + nameof(ViewModel), + typeof(TViewModel), + typeof(ReactiveViewCell), + default(TViewModel), + BindingMode.OneWay, + propertyChanged: OnViewModelChanged); + + /// + /// Gets or sets the ViewModel to display. + /// + public TViewModel? ViewModel + { + get => (TViewModel)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + /// + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = value as TViewModel; + } + + /// + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + ViewModel = BindingContext as TViewModel; + } + + private static void OnViewModelChanged(BindableObject bindableObject, object oldValue, object newValue) + { + bindableObject.BindingContext = newValue; + } +} diff --git a/Stellar.Maui/Views/ViewCellBase.cs b/Stellar.Maui/Views/ViewCellBase.cs index 9a2e2b3..fc8f29d 100644 --- a/Stellar.Maui/Views/ViewCellBase.cs +++ b/Stellar.Maui/Views/ViewCellBase.cs @@ -5,6 +5,7 @@ #pragma warning disable CS0618 // Type or member is obsolete using System.ComponentModel; +using ReactiveUI.Maui.Views; namespace Stellar.Maui.Views; diff --git a/Stellar.MauiBlazorHybridSample/GlobalUsings.cs b/Stellar.MauiBlazorHybridSample/GlobalUsings.cs index adfb3db..bffb3e3 100644 --- a/Stellar.MauiBlazorHybridSample/GlobalUsings.cs +++ b/Stellar.MauiBlazorHybridSample/GlobalUsings.cs @@ -2,6 +2,7 @@ global using System.Reactive.Disposables; global using System.Reactive.Linq; global using ReactiveUI; +global using ReactiveUI.Reactive; global using ReactiveUI.SourceGenerators; global using Stellar.Maui; global using Stellar.Maui.Pages; diff --git a/Stellar.MauiSample/GlobalUsings.cs b/Stellar.MauiSample/GlobalUsings.cs index 97d8ff8..8ae01f2 100644 --- a/Stellar.MauiSample/GlobalUsings.cs +++ b/Stellar.MauiSample/GlobalUsings.cs @@ -3,6 +3,7 @@ global using System.Reactive.Linq; global using CommunityToolkit.Maui.Markup; global using ReactiveUI; +global using ReactiveUI.Reactive; global using ReactiveUI.SourceGenerators; global using Stellar.Maui; global using Stellar.Maui.Pages; diff --git a/Stellar.MauiSample/Stellar.MauiSample.csproj b/Stellar.MauiSample/Stellar.MauiSample.csproj index 7ec06a2..1170d58 100644 --- a/Stellar.MauiSample/Stellar.MauiSample.csproj +++ b/Stellar.MauiSample/Stellar.MauiSample.csproj @@ -72,7 +72,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Stellar.UnitTests/Usings.cs b/Stellar.UnitTests/Usings.cs index 9df1d42..28a1f4e 100644 --- a/Stellar.UnitTests/Usings.cs +++ b/Stellar.UnitTests/Usings.cs @@ -1 +1,2 @@ -global using Xunit; +global using ReactiveUI.Reactive; +global using Xunit; diff --git a/Stellar.Uno/Extensions/BuilderExtensions.cs b/Stellar.Uno/Extensions/BuilderExtensions.cs new file mode 100644 index 0000000..2b58068 --- /dev/null +++ b/Stellar.Uno/Extensions/BuilderExtensions.cs @@ -0,0 +1,51 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Dispatching; +using ReactiveUI.Reactive.Builder; + +namespace Stellar.Uno; + +public static class BuilderExtensions +{ + public static IServiceCollection UseStellarComponents(this IServiceCollection services) + { + UseStellarComponents(services); + + services.ConfigureStellarComponents(typeof(TStellarAssembly).GetTypeInfo().Assembly); + + return services; + } + + /// + /// Registers Stellar's Uno Platform services. Must be called from the UI thread + /// (e.g. in Application.OnLaunched) so the UI thread's + /// can be captured for the main-thread scheduler. + /// + /// The service collection. + /// The same service collection, for chaining. + public static IServiceCollection UseStellarComponents(this IServiceCollection services) + { + var dispatcherQueue = DispatcherQueue.GetForCurrentThread() + ?? throw new InvalidOperationException( + "UseStellarComponents must be called from the UI thread so the DispatcherQueue can be captured."); + + // There is no ReactiveUI 24-compatible Uno platform package, so the activation + // fetcher and schedulers are registered here directly, mirroring Stellar.Avalonia. + RxAppBuilder + .CreateReactiveUIBuilder() + .WithRegistration( + static resolver => resolver.RegisterConstant(new UnoActivationForViewFetcher())) + .WithMainThreadScheduler(new UnoScheduler(dispatcherQueue)) + .WithTaskPoolScheduler(Schedulers.ShortTermThreadPoolScheduler) + .Build(); + + return services; + } + + public static IServiceCollection EnableHotReload(this IServiceCollection services) + { + HotReloadService.HotReloadAware = true; + + return services; + } +} diff --git a/Stellar.Uno/GlobalUsings.cs b/Stellar.Uno/GlobalUsings.cs new file mode 100644 index 0000000..bb463e0 --- /dev/null +++ b/Stellar.Uno/GlobalUsings.cs @@ -0,0 +1,8 @@ +global using System; +global using System.Reactive; +global using System.Reactive.Concurrency; +global using System.Reactive.Disposables; +global using System.Reactive.Linq; +global using System.Reactive.Subjects; +global using ReactiveUI; +global using ReactiveUI.Reactive; diff --git a/Stellar.Uno/HotReloadService.cs b/Stellar.Uno/HotReloadService.cs new file mode 100644 index 0000000..e7fc0a9 --- /dev/null +++ b/Stellar.Uno/HotReloadService.cs @@ -0,0 +1,21 @@ +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. +[assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(Stellar.Uno.HotReloadService))] + +namespace Stellar.Uno; + +public static class HotReloadService +{ + public static bool HotReloadAware { get; set; } + + public static event Action? UpdateApplicationEvent; + + internal static void ClearCache(Type[]? types) + { + } + + internal static void UpdateApplication(Type[]? types) + { + UpdateApplicationEvent?.Invoke(types); + } +} +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. diff --git a/Stellar.Uno/PageBase.cs b/Stellar.Uno/PageBase.cs new file mode 100644 index 0000000..de27e92 --- /dev/null +++ b/Stellar.Uno/PageBase.cs @@ -0,0 +1,117 @@ +using System.ComponentModel; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Navigation; + +namespace Stellar.Uno; + +// Implements IViewFor directly: there is no ReactiveUI 24-compatible Uno platform +// package to supply a ReactivePage base class, so Stellar provides the ViewModel +// dependency property and DataContext syncing itself. +public abstract class PageBase : Page, IStellarView + where TViewModel : class +{ + public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( + nameof(ViewModel), + typeof(TViewModel), + typeof(PageBase), + new PropertyMetadata(null)); + + [EditorBrowsable(EditorBrowsableState.Never)] + public ViewManager ViewManager { get; } = new UnoViewManager(); + + public TViewModel? ViewModel + { + get => (TViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = value as TViewModel; + } + + public IObservable PageInitialized => ViewManager.Initialized; + + public IObservable Activated => ViewManager.Activated; + + public IObservable Deactivated => ViewManager.Deactivated; + + public IObservable IsAppearing => ViewManager.IsAppearing; + + public IObservable IsDisappearing => ViewManager.IsDisappearing; + + public IObservable NavigatedTo => ViewManager.NavigatedTo; + + public IObservable NavigatedFrom => ViewManager.NavigatedFrom; + + public IObservable Disposed => ViewManager.Disposed; + + public IObservable LifecycleEvents => ViewManager.LifecycleEvents; + + protected PageBase() + : this(manuallyInitialize: true) + { + } + + protected PageBase( + TViewModel? viewModel = null, + bool maintain = false, + bool delayBindingRegistrationUntilAttached = false, + bool manuallyInitialize = true) + { + Loaded += HandleLoaded; + Unloaded += HandleUnloaded; + DataContextChanged += HandleDataContextChanged; + RegisterPropertyChangedCallback( + ViewModelProperty, + (_, _) => ViewManager.PropertyChanged(this, nameof(ViewModel))); + + if (!manuallyInitialize) + { + this.InitializeStellarComponent(viewModel, maintain, delayBindingRegistrationUntilAttached); + } + } + + public virtual void Initialize() + { + } + + public abstract void SetupUserInterface(); + + public abstract void Bind(WeakCompositeDisposable disposables); + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + + ViewManager.OnNavigating(this, NavigationEvent.NavigatedTo); + } + + protected override void OnNavigatedFrom(NavigationEventArgs e) + { + ViewManager.OnNavigating(this, NavigationEvent.NavigatedFrom); + + base.OnNavigatedFrom(e); + } + + private void HandleLoaded(object sender, RoutedEventArgs e) + { + ViewManager.HandleActivated(this); + + ViewManager.OnLifecycle(this, LifecycleEvent.IsAppearing); + } + + private void HandleUnloaded(object sender, RoutedEventArgs e) + { + ViewManager.OnLifecycle(this, LifecycleEvent.IsDisappearing); + + ViewManager.HandleDeactivated(this); + } + + private void HandleDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + ViewModel = args.NewValue as TViewModel; + } +} diff --git a/Stellar.Uno/Stellar.Uno.csproj b/Stellar.Uno/Stellar.Uno.csproj new file mode 100644 index 0000000..9a79e24 --- /dev/null +++ b/Stellar.Uno/Stellar.Uno.csproj @@ -0,0 +1,27 @@ + + + net10.0 + true + enable + enable + StellarUI.Uno + Stellar UI - Uno Platform + Eight-Bot + Eight-Bot 2026 + https://github.com/TheEightBot/StellarUI/blob/main/LICENSE + https://github.com/TheEightBot/StellarUI + Uno Platform;MVVM;Reactive UI;Reactive Extensions;Eight-Bot + + + + + + + + + + diff --git a/Stellar.Uno/UnoActivationForViewFetcher.cs b/Stellar.Uno/UnoActivationForViewFetcher.cs new file mode 100644 index 0000000..e8916e3 --- /dev/null +++ b/Stellar.Uno/UnoActivationForViewFetcher.cs @@ -0,0 +1,37 @@ +using Microsoft.UI.Xaml; + +namespace Stellar.Uno; + +/// +/// Determines when Uno Platform views are activated and deactivated so that +/// WhenActivated works for them. There is no ReactiveUI 24-compatible Uno +/// platform package, so Stellar provides this itself. Views follow +/// / . +/// +public sealed class UnoActivationForViewFetcher : IActivationForViewFetcher +{ + public int GetAffinityForView(Type view) => + typeof(FrameworkElement).IsAssignableFrom(view) ? 10 : 0; + + public IObservable GetActivationForView(IActivatableView view) + { + if (view is not FrameworkElement element) + { + return Observable.Return(false); + } + + var loaded = Observable + .FromEventPattern( + handler => element.Loaded += handler, + handler => element.Loaded -= handler) + .Select(static _ => true); + + var unloaded = Observable + .FromEventPattern( + handler => element.Unloaded += handler, + handler => element.Unloaded -= handler) + .Select(static _ => false); + + return loaded.Merge(unloaded).DistinctUntilChanged(); + } +} diff --git a/Stellar.Uno/UnoScheduler.cs b/Stellar.Uno/UnoScheduler.cs new file mode 100644 index 0000000..72d362f --- /dev/null +++ b/Stellar.Uno/UnoScheduler.cs @@ -0,0 +1,80 @@ +using Microsoft.UI.Dispatching; + +namespace Stellar.Uno; + +/// +/// An that schedules work onto an Uno Platform UI thread via its +/// . There is no ReactiveUI 24-compatible Uno platform +/// package, so Stellar provides this itself. Work scheduled with no due time from the UI +/// thread runs inline. +/// +public sealed class UnoScheduler : IScheduler +{ + private readonly DispatcherQueue _dispatcherQueue; + + public UnoScheduler(DispatcherQueue dispatcherQueue) + { + ArgumentNullException.ThrowIfNull(dispatcherQueue); + + _dispatcherQueue = dispatcherQueue; + } + + public DateTimeOffset Now => DateTimeOffset.Now; + + public IDisposable Schedule(TState state, Func action) + { + var innerDisp = new SingleAssignmentDisposable(); + + if (_dispatcherQueue.HasThreadAccess) + { + innerDisp.Disposable = action(this, state); + + return innerDisp; + } + + _dispatcherQueue.TryEnqueue( + () => + { + if (!innerDisp.IsDisposed) + { + innerDisp.Disposable = action(this, state); + } + }); + + return innerDisp; + } + + public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) + { + if (dueTime <= TimeSpan.Zero) + { + return Schedule(state, action); + } + + var innerDisp = new SingleAssignmentDisposable(); + var timer = _dispatcherQueue.CreateTimer(); + timer.Interval = dueTime; + timer.IsRepeating = false; + timer.Tick += (_, _) => + { + if (!innerDisp.IsDisposed) + { + innerDisp.Disposable = action(this, state); + } + }; + timer.Start(); + + return new CompositeDisposable( + Disposable.Create(timer, static t => t.Stop()), + innerDisp); + } + + public IDisposable Schedule(TState state, DateTimeOffset dueTime, Func action) + { + var relative = dueTime - Now; + + return relative <= TimeSpan.Zero + ? Schedule(state, action) + : Schedule(state, relative, action); + } +} diff --git a/Stellar.Uno/UnoViewManager.cs b/Stellar.Uno/UnoViewManager.cs new file mode 100644 index 0000000..c182378 --- /dev/null +++ b/Stellar.Uno/UnoViewManager.cs @@ -0,0 +1,66 @@ +using Microsoft.UI.Xaml; + +namespace Stellar.Uno; + +public class UnoViewManager : ViewManager + where TViewModel : class +{ + // Null whenever no view is activated, which HandleDeactivated relies on. + private IStellarView? _view; + + public override void HandleActivated(IStellarView view) + { + base.HandleActivated(view); + + if (HotReloadService.HotReloadAware) + { + _view = view; + HotReloadService.UpdateApplicationEvent -= HandleHotReload; + HotReloadService.UpdateApplicationEvent += HandleHotReload; + } + } + + public override void HandleDeactivated(IStellarView view) + { + if (HotReloadService.HotReloadAware) + { + _view = null; + HotReloadService.UpdateApplicationEvent -= HandleHotReload; + } + + base.HandleDeactivated(view); + } + +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + private void HandleHotReload(Type[]? updatedTypes) +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + { + if (_view is not FrameworkElement fe) + { + return; + } + + fe.DispatcherQueue.TryEnqueue( + () => + { + var view = _view; + + if (view is null) + { + return; + } + + var maintainStatus = view.ViewManager.Maintain; + + view.ViewManager.Maintain = false; + + view.ViewManager.UnregisterBindings(view); + + view.ViewManager.Maintain = maintainStatus; + + view.SetupUserInterface(); + + view.ViewManager.RegisterBindings(view); + }); + } +} diff --git a/Stellar.Uno/UserControlBase.cs b/Stellar.Uno/UserControlBase.cs new file mode 100644 index 0000000..0a02f52 --- /dev/null +++ b/Stellar.Uno/UserControlBase.cs @@ -0,0 +1,94 @@ +using System.ComponentModel; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Stellar.Uno; + +// Implements IViewFor directly: there is no ReactiveUI 24-compatible Uno platform +// package to supply a ReactiveUserControl base class, so Stellar provides the ViewModel +// dependency property and DataContext syncing itself. +public abstract class UserControlBase : UserControl, IStellarView + where TViewModel : class +{ + public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( + nameof(ViewModel), + typeof(TViewModel), + typeof(UserControlBase), + new PropertyMetadata(null)); + + [EditorBrowsable(EditorBrowsableState.Never)] + public ViewManager ViewManager { get; } = new UnoViewManager(); + + public TViewModel? ViewModel + { + get => (TViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = value as TViewModel; + } + + public IObservable UserControlInitialized => ViewManager.Initialized; + + public IObservable Activated => ViewManager.Activated; + + public IObservable Deactivated => ViewManager.Deactivated; + + public IObservable Disposed => ViewManager.Disposed; + + public IObservable LifecycleEvents => ViewManager.LifecycleEvents; + + protected UserControlBase() + : this(manuallyInitialize: true) + { + } + + protected UserControlBase( + TViewModel? viewModel = null, + bool maintain = false, + bool delayBindingRegistrationUntilAttached = false, + bool manuallyInitialize = true) + { + Loaded += HandleLoaded; + Unloaded += HandleUnloaded; + DataContextChanged += HandleDataContextChanged; + RegisterPropertyChangedCallback( + ViewModelProperty, + (_, _) => ViewManager.PropertyChanged(this, nameof(ViewModel))); + + if (!manuallyInitialize) + { + this.InitializeStellarComponent(viewModel, maintain, delayBindingRegistrationUntilAttached); + } + } + + public virtual void Initialize() + { + } + + public abstract void SetupUserInterface(); + + public abstract void Bind(WeakCompositeDisposable disposables); + + private void HandleLoaded(object sender, RoutedEventArgs e) + { + ViewManager.HandleActivated(this); + + ViewManager.OnLifecycle(this, LifecycleEvent.IsAppearing); + } + + private void HandleUnloaded(object sender, RoutedEventArgs e) + { + ViewManager.OnLifecycle(this, LifecycleEvent.IsDisappearing); + + ViewManager.HandleDeactivated(this); + } + + private void HandleDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) + { + ViewModel = args.NewValue as TViewModel; + } +} diff --git a/Stellar.WinUI/Extensions/BuilderExtensions.cs b/Stellar.WinUI/Extensions/BuilderExtensions.cs new file mode 100644 index 0000000..2157fb8 --- /dev/null +++ b/Stellar.WinUI/Extensions/BuilderExtensions.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using ReactiveUI.Builder; +using ReactiveUI.Reactive.Builder; + +namespace Stellar.WinUI; + +public static class BuilderExtensions +{ + public static IServiceCollection UseStellarComponents(this IServiceCollection services) + { + UseStellarComponents(services); + + services.ConfigureStellarComponents(typeof(TStellarAssembly).GetTypeInfo().Assembly); + + return services; + } + + public static IServiceCollection UseStellarComponents(this IServiceCollection services) + { + // WithWinUI registers the WinUI platform services (activation fetcher, + // main-thread scheduler, binding hooks) that ReactiveUI's old + // InitializeReactiveUI selected. + RxAppBuilder + .CreateReactiveUIBuilder() + .WithWinUI() + .WithTaskPoolScheduler(Schedulers.ShortTermThreadPoolScheduler) + .Build(); + + return services; + } + + public static IServiceCollection EnableHotReload(this IServiceCollection services) + { + HotReloadService.HotReloadAware = true; + + return services; + } +} diff --git a/Stellar.WinUI/GlobalUsings.cs b/Stellar.WinUI/GlobalUsings.cs new file mode 100644 index 0000000..bb463e0 --- /dev/null +++ b/Stellar.WinUI/GlobalUsings.cs @@ -0,0 +1,8 @@ +global using System; +global using System.Reactive; +global using System.Reactive.Concurrency; +global using System.Reactive.Disposables; +global using System.Reactive.Linq; +global using System.Reactive.Subjects; +global using ReactiveUI; +global using ReactiveUI.Reactive; diff --git a/Stellar.WinUI/HotReloadService.cs b/Stellar.WinUI/HotReloadService.cs new file mode 100644 index 0000000..11c0a0c --- /dev/null +++ b/Stellar.WinUI/HotReloadService.cs @@ -0,0 +1,21 @@ +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. +[assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(Stellar.WinUI.HotReloadService))] + +namespace Stellar.WinUI; + +public static class HotReloadService +{ + public static bool HotReloadAware { get; set; } + + public static event Action? UpdateApplicationEvent; + + internal static void ClearCache(Type[]? types) + { + } + + internal static void UpdateApplication(Type[]? types) + { + UpdateApplicationEvent?.Invoke(types); + } +} +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. diff --git a/Stellar.WinUI/PageBase.cs b/Stellar.WinUI/PageBase.cs new file mode 100644 index 0000000..44616a9 --- /dev/null +++ b/Stellar.WinUI/PageBase.cs @@ -0,0 +1,92 @@ +using System.ComponentModel; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Navigation; + +namespace Stellar.WinUI; + +// ReactivePage supplies the ViewModel dependency property and IViewFor plumbing; Stellar +// layers its ViewManager lifecycle on top. WinUI has no all-property change virtual, so +// ViewModel changes are forwarded through RegisterPropertyChangedCallback instead. +public abstract class PageBase : ReactivePage, IStellarView + where TViewModel : class +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public ViewManager ViewManager { get; } = new WinUIViewManager(); + + public IObservable PageInitialized => ViewManager.Initialized; + + public IObservable Activated => ViewManager.Activated; + + public IObservable Deactivated => ViewManager.Deactivated; + + public IObservable IsAppearing => ViewManager.IsAppearing; + + public IObservable IsDisappearing => ViewManager.IsDisappearing; + + public IObservable NavigatedTo => ViewManager.NavigatedTo; + + public IObservable NavigatedFrom => ViewManager.NavigatedFrom; + + public IObservable Disposed => ViewManager.Disposed; + + public IObservable LifecycleEvents => ViewManager.LifecycleEvents; + + protected PageBase() + : this(manuallyInitialize: true) + { + } + + protected PageBase( + TViewModel? viewModel = null, + bool maintain = false, + bool delayBindingRegistrationUntilAttached = false, + bool manuallyInitialize = true) + { + Loaded += HandleLoaded; + Unloaded += HandleUnloaded; + RegisterPropertyChangedCallback( + ViewModelProperty, + (_, _) => ViewManager.PropertyChanged(this, nameof(ViewModel))); + + if (!manuallyInitialize) + { + this.InitializeStellarComponent(viewModel, maintain, delayBindingRegistrationUntilAttached); + } + } + + public virtual void Initialize() + { + } + + public abstract void SetupUserInterface(); + + public abstract void Bind(WeakCompositeDisposable disposables); + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + + ViewManager.OnNavigating(this, NavigationEvent.NavigatedTo); + } + + protected override void OnNavigatedFrom(NavigationEventArgs e) + { + ViewManager.OnNavigating(this, NavigationEvent.NavigatedFrom); + + base.OnNavigatedFrom(e); + } + + private void HandleLoaded(object sender, RoutedEventArgs e) + { + ViewManager.HandleActivated(this); + + ViewManager.OnLifecycle(this, LifecycleEvent.IsAppearing); + } + + private void HandleUnloaded(object sender, RoutedEventArgs e) + { + ViewManager.OnLifecycle(this, LifecycleEvent.IsDisappearing); + + ViewManager.HandleDeactivated(this); + } +} diff --git a/Stellar.WinUI/Stellar.WinUI.csproj b/Stellar.WinUI/Stellar.WinUI.csproj new file mode 100644 index 0000000..5635d79 --- /dev/null +++ b/Stellar.WinUI/Stellar.WinUI.csproj @@ -0,0 +1,32 @@ + + + net10.0-windows10.0.19041.0 + true + + true + false + false + false + true + enable + enable + StellarUI.WinUI + Stellar UI - WinUI 3 + Eight-Bot + Eight-Bot 2026 + https://github.com/TheEightBot/StellarUI/blob/main/LICENSE + https://github.com/TheEightBot/StellarUI + WinUI;Windows App SDK;MVVM;Reactive UI;Reactive Extensions;Eight-Bot + + + + + + + + + diff --git a/Stellar.WinUI/UserControlBase.cs b/Stellar.WinUI/UserControlBase.cs new file mode 100644 index 0000000..f9a4cb1 --- /dev/null +++ b/Stellar.WinUI/UserControlBase.cs @@ -0,0 +1,69 @@ +using System.ComponentModel; +using Microsoft.UI.Xaml; + +namespace Stellar.WinUI; + +// ReactiveUserControl supplies the ViewModel dependency property and IViewFor plumbing; +// Stellar layers its ViewManager lifecycle on top. WinUI has no all-property change +// virtual, so ViewModel changes are forwarded through RegisterPropertyChangedCallback. +public abstract class UserControlBase : ReactiveUserControl, IStellarView + where TViewModel : class +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public ViewManager ViewManager { get; } = new WinUIViewManager(); + + public IObservable UserControlInitialized => ViewManager.Initialized; + + public IObservable Activated => ViewManager.Activated; + + public IObservable Deactivated => ViewManager.Deactivated; + + public IObservable Disposed => ViewManager.Disposed; + + public IObservable LifecycleEvents => ViewManager.LifecycleEvents; + + protected UserControlBase() + : this(manuallyInitialize: true) + { + } + + protected UserControlBase( + TViewModel? viewModel = null, + bool maintain = false, + bool delayBindingRegistrationUntilAttached = false, + bool manuallyInitialize = true) + { + Loaded += HandleLoaded; + Unloaded += HandleUnloaded; + RegisterPropertyChangedCallback( + ViewModelProperty, + (_, _) => ViewManager.PropertyChanged(this, nameof(ViewModel))); + + if (!manuallyInitialize) + { + this.InitializeStellarComponent(viewModel, maintain, delayBindingRegistrationUntilAttached); + } + } + + public virtual void Initialize() + { + } + + public abstract void SetupUserInterface(); + + public abstract void Bind(WeakCompositeDisposable disposables); + + private void HandleLoaded(object sender, RoutedEventArgs e) + { + ViewManager.HandleActivated(this); + + ViewManager.OnLifecycle(this, LifecycleEvent.IsAppearing); + } + + private void HandleUnloaded(object sender, RoutedEventArgs e) + { + ViewManager.OnLifecycle(this, LifecycleEvent.IsDisappearing); + + ViewManager.HandleDeactivated(this); + } +} diff --git a/Stellar.WinUI/WinUIViewManager.cs b/Stellar.WinUI/WinUIViewManager.cs new file mode 100644 index 0000000..863ee8e --- /dev/null +++ b/Stellar.WinUI/WinUIViewManager.cs @@ -0,0 +1,66 @@ +using Microsoft.UI.Xaml; + +namespace Stellar.WinUI; + +public class WinUIViewManager : ViewManager + where TViewModel : class +{ + // Null whenever no view is activated, which HandleDeactivated relies on. + private IStellarView? _view; + + public override void HandleActivated(IStellarView view) + { + base.HandleActivated(view); + + if (HotReloadService.HotReloadAware) + { + _view = view; + HotReloadService.UpdateApplicationEvent -= HandleHotReload; + HotReloadService.UpdateApplicationEvent += HandleHotReload; + } + } + + public override void HandleDeactivated(IStellarView view) + { + if (HotReloadService.HotReloadAware) + { + _view = null; + HotReloadService.UpdateApplicationEvent -= HandleHotReload; + } + + base.HandleDeactivated(view); + } + +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + private void HandleHotReload(Type[]? updatedTypes) +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + { + if (_view is not FrameworkElement fe) + { + return; + } + + fe.DispatcherQueue.TryEnqueue( + () => + { + var view = _view; + + if (view is null) + { + return; + } + + var maintainStatus = view.ViewManager.Maintain; + + view.ViewManager.Maintain = false; + + view.ViewManager.UnregisterBindings(view); + + view.ViewManager.Maintain = maintainStatus; + + view.SetupUserInterface(); + + view.ViewManager.RegisterBindings(view); + }); + } +} diff --git a/Stellar.Wpf/Extensions/BuilderExtensions.cs b/Stellar.Wpf/Extensions/BuilderExtensions.cs new file mode 100644 index 0000000..6ad227c --- /dev/null +++ b/Stellar.Wpf/Extensions/BuilderExtensions.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using ReactiveUI.Reactive.Builder; + +namespace Stellar.Wpf; + +public static class BuilderExtensions +{ + public static IServiceCollection UseStellarComponents(this IServiceCollection services) + { + UseStellarComponents(services); + + services.ConfigureStellarComponents(typeof(TStellarAssembly).GetTypeInfo().Assembly); + + return services; + } + + public static IServiceCollection UseStellarComponents(this IServiceCollection services) + { + // WithWpf registers the WPF platform services (activation fetcher, main-thread + // scheduler, binding hooks) that ReactiveUI's old InitializeReactiveUI selected. + RxAppBuilder + .CreateReactiveUIBuilder() + .WithWpf() + .WithTaskPoolScheduler(Schedulers.ShortTermThreadPoolScheduler) + .Build(); + + return services; + } + + public static IServiceCollection EnableHotReload(this IServiceCollection services) + { + HotReloadService.HotReloadAware = true; + + return services; + } +} diff --git a/Stellar.Wpf/GlobalUsings.cs b/Stellar.Wpf/GlobalUsings.cs new file mode 100644 index 0000000..bb463e0 --- /dev/null +++ b/Stellar.Wpf/GlobalUsings.cs @@ -0,0 +1,8 @@ +global using System; +global using System.Reactive; +global using System.Reactive.Concurrency; +global using System.Reactive.Disposables; +global using System.Reactive.Linq; +global using System.Reactive.Subjects; +global using ReactiveUI; +global using ReactiveUI.Reactive; diff --git a/Stellar.Wpf/HotReloadService.cs b/Stellar.Wpf/HotReloadService.cs new file mode 100644 index 0000000..40771f2 --- /dev/null +++ b/Stellar.Wpf/HotReloadService.cs @@ -0,0 +1,21 @@ +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. +[assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(Stellar.Wpf.HotReloadService))] + +namespace Stellar.Wpf; + +public static class HotReloadService +{ + public static bool HotReloadAware { get; set; } + + public static event Action? UpdateApplicationEvent; + + internal static void ClearCache(Type[]? types) + { + } + + internal static void UpdateApplication(Type[]? types) + { + UpdateApplicationEvent?.Invoke(types); + } +} +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. diff --git a/Stellar.Wpf/Stellar.Wpf.csproj b/Stellar.Wpf/Stellar.Wpf.csproj new file mode 100644 index 0000000..19bfeb8 --- /dev/null +++ b/Stellar.Wpf/Stellar.Wpf.csproj @@ -0,0 +1,28 @@ + + + + net10.0-windows10.0.19041.0 + true + + true + true + enable + enable + StellarUI.Wpf + Stellar UI - WPF + Eight-Bot + Eight-Bot 2026 + https://github.com/TheEightBot/StellarUI/blob/main/LICENSE + https://github.com/TheEightBot/StellarUI + WPF;MVVM;Reactive UI;Reactive Extensions;Eight-Bot + + + + + + + + diff --git a/Stellar.Wpf/UserControlBase.cs b/Stellar.Wpf/UserControlBase.cs new file mode 100644 index 0000000..17e825d --- /dev/null +++ b/Stellar.Wpf/UserControlBase.cs @@ -0,0 +1,137 @@ +using System.ComponentModel; +using System.Windows; +using ReactiveUI.Reactive; + +namespace Stellar.Wpf; + +// ReactiveUserControl supplies the ViewModel dependency property and IViewFor plumbing; +// Stellar layers its ViewManager lifecycle on top. WPF has no virtual for Loaded/Unloaded, +// so the constructor subscribes to the routed events instead. +public abstract class UserControlBase : ReactiveUserControl, IStellarView + where TViewModel : class +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public ViewManager ViewManager { get; } = new WpfViewManager(); + + public IObservable UserControlInitialized => ViewManager.Initialized; + + public IObservable Activated => ViewManager.Activated; + + public IObservable Deactivated => ViewManager.Deactivated; + + public IObservable Disposed => ViewManager.Disposed; + + public IObservable LifecycleEvents => ViewManager.LifecycleEvents; + + protected UserControlBase() + : this(manuallyInitialize: true) + { + } + + protected UserControlBase( + TViewModel? viewModel = null, + bool maintain = false, + bool delayBindingRegistrationUntilAttached = false, + bool manuallyInitialize = true) + { + Loaded += HandleLoaded; + Unloaded += HandleUnloaded; + + if (!manuallyInitialize) + { + this.InitializeStellarComponent(viewModel, maintain, delayBindingRegistrationUntilAttached); + } + } + + public virtual void Initialize() + { + } + + public abstract void SetupUserInterface(); + + public abstract void Bind(WeakCompositeDisposable disposables); + + protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) + { + ViewManager.PropertyChanged(this, e.Property.Name); + + base.OnPropertyChanged(e); + } + + private void HandleLoaded(object sender, RoutedEventArgs e) + { + ViewManager.HandleActivated(this); + + ViewManager.OnLifecycle(this, LifecycleEvent.IsAppearing); + } + + private void HandleUnloaded(object sender, RoutedEventArgs e) + { + ViewManager.OnLifecycle(this, LifecycleEvent.IsDisappearing); + + ViewManager.HandleDeactivated(this); + } +} + +public abstract class UserControlBase : ReactiveUserControl, IStellarView + where TViewModel : class +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public ViewManager ViewManager { get; } = new WpfViewManager(); + + public IObservable UserControlInitialized => ViewManager.Initialized; + + public IObservable Activated => ViewManager.Activated; + + public IObservable Deactivated => ViewManager.Deactivated; + + public IObservable Disposed => ViewManager.Disposed; + + public IObservable LifecycleEvents => ViewManager.LifecycleEvents; + + protected UserControlBase() + { + Loaded += HandleLoaded; + Unloaded += HandleUnloaded; + DataContextChanged += HandleDataContextChanged; + } + + public virtual void Initialize() + { + } + + public abstract void SetupUserInterface(); + + public abstract void Bind(WeakCompositeDisposable disposables); + + protected abstract void MapDataModelToViewModel(TViewModel viewModel, TDataModel dataModel); + + protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) + { + ViewManager.PropertyChanged(this, e.Property.Name); + + base.OnPropertyChanged(e); + } + + private void HandleLoaded(object sender, RoutedEventArgs e) + { + ViewManager.HandleActivated(this); + + ViewManager.OnLifecycle(this, LifecycleEvent.IsAppearing); + } + + private void HandleUnloaded(object sender, RoutedEventArgs e) + { + ViewManager.OnLifecycle(this, LifecycleEvent.IsDisappearing); + + ViewManager.HandleDeactivated(this); + } + + private void HandleDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if (ViewModel is not null && e.NewValue is TDataModel dataModel) + { + MapDataModelToViewModel(ViewModel, dataModel); + } + } +} diff --git a/Stellar.Wpf/WindowBase.cs b/Stellar.Wpf/WindowBase.cs new file mode 100644 index 0000000..d830653 --- /dev/null +++ b/Stellar.Wpf/WindowBase.cs @@ -0,0 +1,83 @@ +using System.ComponentModel; +using System.Windows; +using ReactiveUI.Reactive; + +namespace Stellar.Wpf; + +// ReactiveWindow supplies the ViewModel dependency property and IViewFor plumbing; +// Stellar layers its ViewManager lifecycle on top, mirroring Stellar.Avalonia's WindowBase. +public abstract class WindowBase : ReactiveWindow, IStellarView + where TViewModel : class +{ + [EditorBrowsable(EditorBrowsableState.Never)] + public ViewManager ViewManager { get; } = new WpfViewManager(); + + public IObservable WindowInitialized => ViewManager.Initialized; + + public IObservable WindowActivated => ViewManager.Activated; + + public IObservable WindowDeactivated => ViewManager.Deactivated; + + public IObservable IsAppearing => ViewManager.IsAppearing; + + public IObservable IsDisappearing => ViewManager.IsDisappearing; + + public IObservable Disposed => ViewManager.Disposed; + + public IObservable LifecycleEvents => ViewManager.LifecycleEvents; + + protected WindowBase() + : this(manuallyInitialize: true) + { + } + + protected WindowBase( + TViewModel? viewModel = null, + bool maintain = false, + bool delayBindingRegistrationUntilAttached = false, + bool manuallyInitialize = true) + { + if (!manuallyInitialize) + { + this.InitializeStellarComponent(viewModel, maintain, delayBindingRegistrationUntilAttached); + } + } + + public virtual void Initialize() + { + } + + public abstract void SetupUserInterface(); + + public abstract void Bind(WeakCompositeDisposable disposables); + + protected override void OnInitialized(EventArgs e) + { + base.OnInitialized(e); + + ViewManager.HandleActivated(this); + } + + protected override void OnContentRendered(EventArgs e) + { + base.OnContentRendered(e); + + ViewManager.OnLifecycle(this, LifecycleEvent.IsAppearing); + } + + protected override void OnClosing(CancelEventArgs e) + { + ViewManager.OnLifecycle(this, LifecycleEvent.IsDisappearing); + + ViewManager.HandleDeactivated(this); + + base.OnClosing(e); + } + + protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) + { + ViewManager.PropertyChanged(this, e.Property.Name); + + base.OnPropertyChanged(e); + } +} diff --git a/Stellar.Wpf/WpfViewManager.cs b/Stellar.Wpf/WpfViewManager.cs new file mode 100644 index 0000000..4748c39 --- /dev/null +++ b/Stellar.Wpf/WpfViewManager.cs @@ -0,0 +1,60 @@ +using System.Windows; + +namespace Stellar.Wpf; + +public class WpfViewManager : ViewManager + where TViewModel : class +{ + // Null whenever no view is activated, which HandleDeactivated relies on. + private IStellarView? _view; + + public override void HandleActivated(IStellarView view) + { + base.HandleActivated(view); + + if (HotReloadService.HotReloadAware) + { + _view = view; + HotReloadService.UpdateApplicationEvent -= HandleHotReload; + HotReloadService.UpdateApplicationEvent += HandleHotReload; + } + } + + public override void HandleDeactivated(IStellarView view) + { + if (HotReloadService.HotReloadAware) + { + _view = null; + HotReloadService.UpdateApplicationEvent -= HandleHotReload; + } + + base.HandleDeactivated(view); + } + +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + private void HandleHotReload(Type[]? updatedTypes) +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + { + if (_view is null) + { + return; + } + + Application.Current?.Dispatcher + .Invoke( + () => + { + var maintainStatus = _view.ViewManager.Maintain; + + _view.ViewManager.Maintain = false; + + _view.ViewManager.UnregisterBindings(_view); + + _view.ViewManager.Maintain = maintainStatus; + + _view.SetupUserInterface(); + + _view.ViewManager.RegisterBindings(_view); + }); + } +} diff --git a/Stellar.sln b/Stellar.sln index 6ae7901..2035b2e 100644 --- a/Stellar.sln +++ b/Stellar.sln @@ -49,6 +49,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Stellar.DiskDataCache", "St EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Stellar.SourceGenerators", "Stellar.SourceGenerators\Stellar.SourceGenerators.csproj", "{037D57FD-AB7C-4F69-8841-1A655FC86145}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Stellar.Wpf", "Stellar.Wpf\Stellar.Wpf.csproj", "{D3E861EB-BBD7-405F-AB85-04F928E39E38}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Stellar.WinUI", "Stellar.WinUI\Stellar.WinUI.csproj", "{B51B97EC-F16B-4092-950D-260D71CF7A65}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Stellar.Uno", "Stellar.Uno\Stellar.Uno.csproj", "{51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -216,6 +222,42 @@ Global {037D57FD-AB7C-4F69-8841-1A655FC86145}.Release|x64.Build.0 = Release|Any CPU {037D57FD-AB7C-4F69-8841-1A655FC86145}.Release|x86.ActiveCfg = Release|Any CPU {037D57FD-AB7C-4F69-8841-1A655FC86145}.Release|x86.Build.0 = Release|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Debug|x64.ActiveCfg = Debug|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Debug|x64.Build.0 = Debug|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Debug|x86.ActiveCfg = Debug|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Debug|x86.Build.0 = Debug|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Release|Any CPU.Build.0 = Release|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Release|x64.ActiveCfg = Release|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Release|x64.Build.0 = Release|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Release|x86.ActiveCfg = Release|Any CPU + {D3E861EB-BBD7-405F-AB85-04F928E39E38}.Release|x86.Build.0 = Release|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Debug|x64.ActiveCfg = Debug|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Debug|x64.Build.0 = Debug|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Debug|x86.ActiveCfg = Debug|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Debug|x86.Build.0 = Debug|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Release|Any CPU.Build.0 = Release|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Release|x64.ActiveCfg = Release|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Release|x64.Build.0 = Release|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Release|x86.ActiveCfg = Release|Any CPU + {B51B97EC-F16B-4092-950D-260D71CF7A65}.Release|x86.Build.0 = Release|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Debug|x64.Build.0 = Debug|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Debug|x86.Build.0 = Debug|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Release|Any CPU.Build.0 = Release|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Release|x64.ActiveCfg = Release|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Release|x64.Build.0 = Release|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Release|x86.ActiveCfg = Release|Any CPU + {51D9C2E2-D338-45BF-B904-5C8B1DDF68FF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Stellar.slnf b/Stellar.slnf index 7ab2dc8..2417a5b 100644 --- a/Stellar.slnf +++ b/Stellar.slnf @@ -10,7 +10,10 @@ "Stellar.DiskDataCache\\Stellar.DiskDataCache.csproj", "Stellar.FluentValidation\\Stellar.FluentValidation.csproj", "Stellar.SourceGenerators\\Stellar.SourceGenerators.csproj", - "Stellar.UnitTests\\Stellar.UnitTests.csproj" + "Stellar.UnitTests\\Stellar.UnitTests.csproj", + "Stellar.Uno\\Stellar.Uno.csproj", + "Stellar.WinUI\\Stellar.WinUI.csproj", + "Stellar.Wpf\\Stellar.Wpf.csproj" ] } } diff --git a/Stellar/GlobalUsings.cs b/Stellar/GlobalUsings.cs index b2683af..c41d875 100644 --- a/Stellar/GlobalUsings.cs +++ b/Stellar/GlobalUsings.cs @@ -6,3 +6,4 @@ global using System.Reactive.Linq; global using ReactiveGenerator; global using ReactiveUI; +global using ReactiveUI.Reactive; diff --git a/Stellar/Stellar.csproj b/Stellar/Stellar.csproj index 7a47009..eda9faf 100644 --- a/Stellar/Stellar.csproj +++ b/Stellar/Stellar.csproj @@ -18,7 +18,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + +