diff --git a/StabilityMatrix.Avalonia/App.axaml b/StabilityMatrix.Avalonia/App.axaml index f33a4d5c..d41fd95c 100644 --- a/StabilityMatrix.Avalonia/App.axaml +++ b/StabilityMatrix.Avalonia/App.axaml @@ -27,6 +27,8 @@ 700 + 32 + avares://StabilityMatrix.Avalonia/Assets/Fonts/NotoSansJP#Noto Sans JP @@ -39,6 +41,7 @@ + diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index d15dc543..1bf930b5 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -352,17 +352,31 @@ public sealed class App : Application t1.attributes is { Length: > 0 } && !t1.t.Name.Contains("Mock", StringComparison.OrdinalIgnoreCase) ) - .Select(t1 => new { Type = t1.t, Attribute = (SingletonAttribute)t1.attributes[0] }); + .Select( + t1 => + new + { + Type = t1.t, + Attributes = t1.attributes.Cast().ToArray() + } + ); foreach (var typePair in singletonTypes) { - if (typePair.Attribute.InterfaceType is null) + foreach (var attribute in typePair.Attributes) { - services.AddSingleton(typePair.Type); - } - else - { - services.AddSingleton(typePair.Attribute.InterfaceType, typePair.Type); + if (attribute.InterfaceType is null) + { + services.AddSingleton(typePair.Type); + } + else if (attribute.ImplType is not null) + { + services.AddSingleton(attribute.InterfaceType, attribute.ImplType); + } + else + { + services.AddSingleton(attribute.InterfaceType, typePair.Type); + } } } diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 4d785605..512548f7 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -103,7 +103,6 @@ public static class DesignData // General services services .AddLogging() - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -437,6 +436,9 @@ public static class DesignData public static InferenceSettingsViewModel InferenceSettingsViewModel => Services.GetRequiredService(); + public static MainSettingsViewModel MainSettingsViewModel => + Services.GetRequiredService(); + public static CheckpointBrowserViewModel CheckpointBrowserViewModel => Services.GetRequiredService(); diff --git a/StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs b/StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs new file mode 100644 index 00000000..121b456c --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/TypedNavigationEventArgs.cs @@ -0,0 +1,10 @@ +using System; + +namespace StabilityMatrix.Avalonia.Models; + +public class TypedNavigationEventArgs : EventArgs +{ + public required Type ViewModelType { get; init; } + + public object? ViewModel { get; init; } +} diff --git a/StabilityMatrix.Avalonia/Services/INavigationService.cs b/StabilityMatrix.Avalonia/Services/INavigationService.cs index 4afa4d9f..8c8ee417 100644 --- a/StabilityMatrix.Avalonia/Services/INavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/INavigationService.cs @@ -1,11 +1,15 @@ -using FluentAvalonia.UI.Controls; +using System; +using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Media.Animation; +using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.ViewModels.Base; namespace StabilityMatrix.Avalonia.Services; -public interface INavigationService +public interface INavigationService { + event EventHandler? TypedNavigation; + /// /// Set the frame to use for navigation. /// diff --git a/StabilityMatrix.Avalonia/Services/NavigationService.cs b/StabilityMatrix.Avalonia/Services/NavigationService.cs index b9fb1952..c22a5eb4 100644 --- a/StabilityMatrix.Avalonia/Services/NavigationService.cs +++ b/StabilityMatrix.Avalonia/Services/NavigationService.cs @@ -4,6 +4,7 @@ using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Media.Animation; using FluentAvalonia.UI.Navigation; using StabilityMatrix.Avalonia.Animations; +using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Core.Attributes; @@ -11,11 +12,20 @@ using StabilityMatrix.Core.Services; namespace StabilityMatrix.Avalonia.Services; -[Singleton(typeof(INavigationService))] -public class NavigationService : INavigationService +[Singleton( + ImplType = typeof(NavigationService), + InterfaceType = typeof(INavigationService) +)] +[Singleton( + ImplType = typeof(NavigationService), + InterfaceType = typeof(INavigationService) +)] +public class NavigationService : INavigationService { private Frame? _frame; + public event EventHandler? TypedNavigation; + /// public void SetFrame(Frame frame) { @@ -60,18 +70,10 @@ public class NavigationService : INavigationService } ); - if (!typeof(TViewModel).IsAssignableTo(typeof(PageViewModelBase))) - return; - - if ( - App.Services.GetService(typeof(MainWindowViewModel)) - is MainWindowViewModel mainViewModel - ) - { - mainViewModel.SelectedCategory = mainViewModel.Pages.FirstOrDefault( - x => x.GetType() == typeof(TViewModel) - ); - } + TypedNavigation?.Invoke( + this, + new TypedNavigationEventArgs { ViewModelType = typeof(TViewModel) } + ); } /// @@ -106,5 +108,14 @@ public class NavigationService : INavigationService TransitionInfoOverride = transitionInfo ?? new SuppressNavigationTransitionInfo() } ); + + TypedNavigation?.Invoke( + this, + new TypedNavigationEventArgs + { + ViewModelType = viewModel.GetType(), + ViewModel = viewModel + } + ); } } diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index a0e6c8cc..4b29bdc7 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -38,6 +38,7 @@ + diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs index bc1d60ff..63e5b131 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceTabViewModelBase.cs @@ -127,7 +127,7 @@ public abstract partial class InferenceTabViewModelBase // ResetViewState(); // TODO: Dock reset not working, using this hack for now to get a new view - var navService = App.Services.GetRequiredService(); + var navService = App.Services.GetRequiredService>(); navService.NavigateTo(new SuppressNavigationTransitionInfo()); ((IPersistentViewProvider)this).AttachedPersistentView = null; navService.NavigateTo(new BetterEntranceNavigationTransition()); diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs index 351f9a64..aad4cc87 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/InferenceConnectionHelpViewModel.cs @@ -27,7 +27,7 @@ namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBase { private readonly ISettingsManager settingsManager; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private readonly IPackageFactory packageFactory; [ObservableProperty] @@ -56,7 +56,7 @@ public partial class InferenceConnectionHelpViewModel : ContentDialogViewModelBa public InferenceConnectionHelpViewModel( ISettingsManager settingsManager, - INavigationService navigationService, + INavigationService navigationService, IPackageFactory packageFactory ) { diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs index a7d1d26d..718630a7 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/OneClickInstallViewModel.cs @@ -30,7 +30,7 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase private readonly IPrerequisiteHelper prerequisiteHelper; private readonly ILogger logger; private readonly IPyRunner pyRunner; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private const string DefaultPackageName = "stable-diffusion-webui"; [ObservableProperty] @@ -71,7 +71,7 @@ public partial class OneClickInstallViewModel : ContentDialogViewModelBase IPrerequisiteHelper prerequisiteHelper, ILogger logger, IPyRunner pyRunner, - INavigationService navigationService + INavigationService navigationService ) { this.settingsManager = settingsManager; diff --git a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs index 8352f6d5..aa296d35 100644 --- a/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/OutputsPageViewModel.cs @@ -49,7 +49,7 @@ public partial class OutputsPageViewModel : PageViewModelBase private readonly ISettingsManager settingsManager; private readonly IPackageFactory packageFactory; private readonly INotificationService notificationService; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private readonly ILogger logger; public override string Title => Resources.Label_OutputsPageTitle; @@ -99,7 +99,7 @@ public partial class OutputsPageViewModel : PageViewModelBase ISettingsManager settingsManager, IPackageFactory packageFactory, INotificationService notificationService, - INavigationService navigationService, + INavigationService navigationService, ILogger logger ) { diff --git a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs index c9893634..182280de 100644 --- a/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/PackageManager/PackageCardViewModel.cs @@ -36,7 +36,7 @@ public partial class PackageCardViewModel : ProgressViewModel private readonly IPackageFactory packageFactory; private readonly INotificationService notificationService; private readonly ISettingsManager settingsManager; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private readonly ServiceManager vmFactory; [ObservableProperty] @@ -80,7 +80,7 @@ public partial class PackageCardViewModel : ProgressViewModel IPackageFactory packageFactory, INotificationService notificationService, ISettingsManager settingsManager, - INavigationService navigationService, + INavigationService navigationService, ServiceManager vmFactory ) { diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs index 80d5c7ba..4ce798cf 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs @@ -1,9 +1,20 @@ -using StabilityMatrix.Avalonia.ViewModels.Base; +using FluentAvalonia.UI.Controls; +using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.Views.Settings; using StabilityMatrix.Core.Attributes; +using Symbol = FluentIcons.Common.Symbol; +using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels.Settings; [View(typeof(InferenceSettingsPage))] -[Singleton] -public partial class InferenceSettingsViewModel : ViewModelBase { } +[Singleton, ManagedService] +public class InferenceSettingsViewModel : PageViewModelBase +{ + /// + public override string Title => "Inference"; + + /// + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs new file mode 100644 index 00000000..628ceb64 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -0,0 +1,950 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls.Notifications; +using Avalonia.Controls.Primitives; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; +using Avalonia.Styling; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DynamicData.Binding; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Media.Animation; +using NLog; +using SkiaSharp; +using StabilityMatrix.Avalonia.Animations; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Extensions; +using StabilityMatrix.Avalonia.Helpers; +using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Models.Inference; +using StabilityMatrix.Avalonia.Models.TagCompletion; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using StabilityMatrix.Avalonia.ViewModels.Inference; +using StabilityMatrix.Avalonia.Views.Dialogs; +using StabilityMatrix.Avalonia.Views.Settings; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; +using Symbol = FluentIcons.Common.Symbol; +using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; + +namespace StabilityMatrix.Avalonia.ViewModels.Settings; + +[View(typeof(MainSettingsPage))] +[Singleton, ManagedService] +public partial class MainSettingsViewModel : PageViewModelBase +{ + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private readonly INotificationService notificationService; + private readonly ISettingsManager settingsManager; + private readonly IPrerequisiteHelper prerequisiteHelper; + private readonly IPyRunner pyRunner; + private readonly ServiceManager dialogFactory; + private readonly ICompletionProvider completionProvider; + private readonly ITrackedDownloadService trackedDownloadService; + private readonly IModelIndexService modelIndexService; + private readonly INavigationService settingsNavigationService; + + public SharedState SharedState { get; } + + public override string Title => "Settings"; + public override IconSource IconSource => + new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; + + // ReSharper disable once MemberCanBeMadeStatic.Global + public string AppVersion => + $"Version {Compat.AppVersion}" + (Program.IsDebugBuild ? " (Debug)" : ""); + + // Theme section + [ObservableProperty] + private string? selectedTheme; + + public IReadOnlyList AvailableThemes { get; } = new[] { "Light", "Dark", "System", }; + + [ObservableProperty] + private CultureInfo selectedLanguage; + + // ReSharper disable once MemberCanBeMadeStatic.Global + public IReadOnlyList AvailableLanguages => Cultures.SupportedCultures; + + public IReadOnlyList AnimationScaleOptions { get; } = + new[] { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, }; + + [ObservableProperty] + private float selectedAnimationScale; + + // Shared folder options + [ObservableProperty] + private bool removeSymlinksOnShutdown; + + // Inference UI section + [ObservableProperty] + private bool isPromptCompletionEnabled = true; + + [ObservableProperty] + private IReadOnlyList availableTagCompletionCsvs = Array.Empty(); + + [ObservableProperty] + private string? selectedTagCompletionCsv; + + [ObservableProperty] + private bool isCompletionRemoveUnderscoresEnabled = true; + + [ObservableProperty] + [CustomValidation(typeof(MainSettingsViewModel), nameof(ValidateOutputImageFileNameFormat))] + private string? outputImageFileNameFormat; + + [ObservableProperty] + private string? outputImageFileNameFormatSample; + + public IEnumerable OutputImageFileNameFormatVars => + FileNameFormatProvider + .GetSample() + .Substitutions.Select( + kv => + new FileNameFormatVar + { + Variable = $"{{{kv.Key}}}", + Example = kv.Value.Invoke() + } + ); + + [ObservableProperty] + private bool isImageViewerPixelGridEnabled = true; + + // Integrations section + [ObservableProperty] + private bool isDiscordRichPresenceEnabled; + + // Debug section + [ObservableProperty] + private string? debugPaths; + + [ObservableProperty] + private string? debugCompatInfo; + + [ObservableProperty] + private string? debugGpuInfo; + + // Info section + private const int VersionTapCountThreshold = 7; + + [ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))] + private int versionTapCount; + + [ObservableProperty] + private bool isVersionTapTeachingTipOpen; + public string VersionFlyoutText => + $"You are {VersionTapCountThreshold - VersionTapCount} clicks away from enabling Debug options."; + + public string DataDirectory => + settingsManager.IsLibraryDirSet ? settingsManager.LibraryDir : "Not set"; + + public MainSettingsViewModel( + INotificationService notificationService, + ISettingsManager settingsManager, + IPrerequisiteHelper prerequisiteHelper, + IPyRunner pyRunner, + ServiceManager dialogFactory, + ITrackedDownloadService trackedDownloadService, + SharedState sharedState, + ICompletionProvider completionProvider, + IModelIndexService modelIndexService, + INavigationService settingsNavigationService + ) + { + this.notificationService = notificationService; + this.settingsManager = settingsManager; + this.prerequisiteHelper = prerequisiteHelper; + this.pyRunner = pyRunner; + this.dialogFactory = dialogFactory; + this.trackedDownloadService = trackedDownloadService; + this.completionProvider = completionProvider; + this.modelIndexService = modelIndexService; + this.settingsNavigationService = settingsNavigationService; + + SharedState = sharedState; + + SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1]; + SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language); + RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown; + SelectedAnimationScale = settingsManager.Settings.AnimationScale; + + settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme); + + settingsManager.RelayPropertyFor( + this, + vm => vm.IsDiscordRichPresenceEnabled, + settings => settings.IsDiscordRichPresenceEnabled, + true + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.SelectedAnimationScale, + settings => settings.AnimationScale + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.SelectedTagCompletionCsv, + settings => settings.TagCompletionCsv + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.IsPromptCompletionEnabled, + settings => settings.IsPromptCompletionEnabled, + true + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.IsCompletionRemoveUnderscoresEnabled, + settings => settings.IsCompletionRemoveUnderscoresEnabled, + true + ); + + this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat) + .Throttle(TimeSpan.FromMilliseconds(50)) + .Subscribe(formatProperty => + { + var provider = FileNameFormatProvider.GetSample(); + var template = formatProperty.Value ?? string.Empty; + + if ( + !string.IsNullOrEmpty(template) + && provider.Validate(template) == ValidationResult.Success + ) + { + var format = FileNameFormat.Parse(template, provider); + OutputImageFileNameFormatSample = format.GetFileName() + ".png"; + } + else + { + // Use default format if empty + var defaultFormat = FileNameFormat.Parse( + FileNameFormat.DefaultTemplate, + provider + ); + OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png"; + } + }); + + settingsManager.RelayPropertyFor( + this, + vm => vm.OutputImageFileNameFormat, + settings => settings.InferenceOutputImageFileNameFormat, + true + ); + + settingsManager.RelayPropertyFor( + this, + vm => vm.IsImageViewerPixelGridEnabled, + settings => settings.IsImageViewerPixelGridEnabled, + true + ); + + DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler( + notificationService, + LogLevel.Warn + ); + ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn); + } + + /// + public override async Task OnLoadedAsync() + { + await base.OnLoadedAsync(); + + await notificationService.TryAsync(completionProvider.Setup()); + + UpdateAvailableTagCompletionCsvs(); + } + + public static ValidationResult ValidateOutputImageFileNameFormat( + string? format, + ValidationContext context + ) + { + return FileNameFormatProvider.GetSample().Validate(format ?? string.Empty); + } + + partial void OnSelectedThemeChanged(string? value) + { + // In case design / tests + if (Application.Current is null) + return; + // Change theme + Application.Current.RequestedThemeVariant = value switch + { + "Dark" => ThemeVariant.Dark, + "Light" => ThemeVariant.Light, + _ => ThemeVariant.Default + }; + } + + partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newValue) + { + if (oldValue is null || newValue.Name == Cultures.Current?.Name) + return; + + // Set locale + if (AvailableLanguages.Contains(newValue)) + { + Logger.Info("Changing language from {Old} to {New}", oldValue, newValue); + + Cultures.TrySetSupportedCulture(newValue); + settingsManager.Transaction(s => s.Language = newValue.Name); + + var dialog = new BetterContentDialog + { + Title = Resources.Label_RelaunchRequired, + Content = Resources.Text_RelaunchRequiredToApplyLanguage, + DefaultButton = ContentDialogButton.Primary, + PrimaryButtonText = Resources.Action_Relaunch, + CloseButtonText = Resources.Action_RelaunchLater + }; + + Dispatcher.UIThread.InvokeAsync(async () => + { + if (await dialog.ShowAsync() == ContentDialogResult.Primary) + { + Process.Start(Compat.AppCurrentPath); + App.Shutdown(); + } + }); + } + else + { + Logger.Info( + "Requested invalid language change from {Old} to {New}", + oldValue, + newValue + ); + } + } + + partial void OnRemoveSymlinksOnShutdownChanged(bool value) + { + settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value); + } + + public async Task ResetCheckpointCache() + { + settingsManager.Transaction(s => s.InstalledModelHashes = new HashSet()); + await Task.Run(() => settingsManager.IndexCheckpoints()); + notificationService.Show( + "Checkpoint cache reset", + "The checkpoint cache has been reset.", + NotificationType.Success + ); + } + + [RelayCommand] + private void NavigateToInferenceSettings() + { + Dispatcher.UIThread.Post( + () => + settingsNavigationService.NavigateTo( + new BetterSlideNavigationTransition + { + Effect = SlideNavigationTransitionEffect.FromRight + } + ) + ); + } + + #region Package Environment + + [RelayCommand] + private async Task OpenEnvVarsDialog() + { + var viewModel = dialogFactory.Get(); + + // Load current settings + var current = + settingsManager.Settings.EnvironmentVariables ?? new Dictionary(); + viewModel.EnvVars = new ObservableCollection( + current.Select(kvp => new EnvVarKeyPair(kvp.Key, kvp.Value)) + ); + + var dialog = new BetterContentDialog + { + Content = new EnvVarsDialog { DataContext = viewModel }, + PrimaryButtonText = Resources.Action_Save, + IsPrimaryButtonEnabled = true, + CloseButtonText = Resources.Action_Cancel, + }; + + if (await dialog.ShowAsync() == ContentDialogResult.Primary) + { + // Save settings + var newEnvVars = viewModel.EnvVars + .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) + .GroupBy(kvp => kvp.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal); + settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars); + } + } + + [RelayCommand] + private async Task CheckPythonVersion() + { + var isInstalled = prerequisiteHelper.IsPythonInstalled; + Logger.Debug($"Check python installed: {isInstalled}"); + // Ensure python installed + if (!prerequisiteHelper.IsPythonInstalled) + { + // Need 7z as well for site packages repack + Logger.Debug("Python not installed, unpacking resources..."); + await prerequisiteHelper.UnpackResourcesIfNecessary(); + Logger.Debug("Unpacked resources, installing python..."); + await prerequisiteHelper.InstallPythonIfNecessary(); + } + + // Get python version + await pyRunner.Initialize(); + var result = await pyRunner.GetVersionInfo(); + // Show dialog box + var dialog = new ContentDialog + { + Title = Resources.Label_PythonVersionInfo, + Content = result, + PrimaryButtonText = Resources.Action_OK, + IsPrimaryButtonEnabled = true + }; + await dialog.ShowAsync(); + } + + #endregion + + #region Inference UI + + private void UpdateAvailableTagCompletionCsvs() + { + if (!settingsManager.IsLibraryDirSet) + return; + + var tagsDir = settingsManager.TagsDirectory; + if (!tagsDir.Exists) + return; + + var csvFiles = tagsDir.Info.EnumerateFiles("*.csv"); + AvailableTagCompletionCsvs = csvFiles.Select(f => f.Name).ToImmutableArray(); + + // Set selected to current if exists + var settingsCsv = settingsManager.Settings.TagCompletionCsv; + if (settingsCsv is not null && AvailableTagCompletionCsvs.Contains(settingsCsv)) + { + SelectedTagCompletionCsv = settingsCsv; + } + } + + [RelayCommand(FlowExceptionsToTaskScheduler = true)] + private async Task ImportTagCsv() + { + var storage = App.StorageProvider; + var files = await storage.OpenFilePickerAsync( + new FilePickerOpenOptions + { + FileTypeFilter = new List + { + new("CSV") { Patterns = new[] { "*.csv" }, } + } + } + ); + + if (files.Count == 0) + return; + + var sourceFile = new FilePath(files[0].TryGetLocalPath()!); + + var tagsDir = settingsManager.TagsDirectory; + tagsDir.Create(); + + // Copy to tags directory + var targetFile = tagsDir.JoinFile(sourceFile.Name); + await sourceFile.CopyToAsync(targetFile); + + // Update index + UpdateAvailableTagCompletionCsvs(); + + // Trigger load + completionProvider.BackgroundLoadFromFile(targetFile, true); + + notificationService.Show( + $"Imported {sourceFile.Name}", + $"The {sourceFile.Name} file has been imported.", + NotificationType.Success + ); + } + #endregion + + #region System + + /// + /// Adds Stability Matrix to Start Menu for the current user. + /// + [RelayCommand] + private async Task AddToStartMenu() + { + if (!Compat.IsWindows) + { + notificationService.Show("Not supported", "This feature is only supported on Windows."); + return; + } + + await using var _ = new MinimumDelay(200, 300); + + var shortcutDir = new DirectoryPath( + Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), + "Programs" + ); + var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk"); + + var appPath = Compat.AppCurrentPath; + var iconPath = shortcutDir.JoinFile("Stability Matrix.ico"); + await Assets.AppIcon.ExtractTo(iconPath); + + WindowsShortcuts.CreateShortcut(shortcutLink, appPath, iconPath, "Stability Matrix"); + + notificationService.Show( + "Added to Start Menu", + "Stability Matrix has been added to the Start Menu.", + NotificationType.Success + ); + } + + /// + /// Add Stability Matrix to Start Menu for all users. + /// Requires Admin elevation. + /// + [RelayCommand] + private async Task AddToGlobalStartMenu() + { + if (!Compat.IsWindows) + { + notificationService.Show("Not supported", "This feature is only supported on Windows."); + return; + } + + // Confirmation dialog + var dialog = new BetterContentDialog + { + Title = + "This will create a shortcut for Stability Matrix in the Start Menu for all users", + Content = "You will be prompted for administrator privileges. Continue?", + PrimaryButtonText = Resources.Action_Yes, + CloseButtonText = Resources.Action_Cancel, + DefaultButton = ContentDialogButton.Primary + }; + + if (await dialog.ShowAsync() != ContentDialogResult.Primary) + { + return; + } + + await using var _ = new MinimumDelay(200, 300); + + var shortcutDir = new DirectoryPath( + Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), + "Programs" + ); + var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk"); + + var appPath = Compat.AppCurrentPath; + var iconPath = shortcutDir.JoinFile("Stability Matrix.ico"); + + // We can't directly write to the targets, so extract to temporary directory first + using var tempDir = new TempDirectoryPath(); + + await Assets.AppIcon.ExtractTo(tempDir.JoinFile("Stability Matrix.ico")); + WindowsShortcuts.CreateShortcut( + tempDir.JoinFile("Stability Matrix.lnk"), + appPath, + iconPath, + "Stability Matrix" + ); + + // Move to target + try + { + var moveLinkResult = await WindowsElevated.MoveFiles( + (tempDir.JoinFile("Stability Matrix.lnk"), shortcutLink), + (tempDir.JoinFile("Stability Matrix.ico"), iconPath) + ); + if (moveLinkResult != 0) + { + notificationService.ShowPersistent( + "Failed to create shortcut", + $"Could not copy shortcut", + NotificationType.Error + ); + } + } + catch (Win32Exception e) + { + // We'll get this exception if user cancels UAC + Logger.Warn(e, "Could not create shortcut"); + notificationService.Show("Could not create shortcut", "", NotificationType.Warning); + return; + } + + notificationService.Show( + "Added to Start Menu", + "Stability Matrix has been added to the Start Menu for all users.", + NotificationType.Success + ); + } + + public async Task PickNewDataDirectory() + { + var viewModel = dialogFactory.Get(); + var dialog = new BetterContentDialog + { + IsPrimaryButtonEnabled = false, + IsSecondaryButtonEnabled = false, + IsFooterVisible = false, + Content = new SelectDataDirectoryDialog { DataContext = viewModel } + }; + + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary) + { + // 1. For portable mode, call settings.SetPortableMode() + if (viewModel.IsPortableMode) + { + settingsManager.SetPortableMode(); + } + // 2. For custom path, call settings.SetLibraryPath(path) + else + { + settingsManager.SetLibraryPath(viewModel.DataDirectory); + } + + // Restart + var restartDialog = new BetterContentDialog + { + Title = "Restart required", + Content = "Stability Matrix must be restarted for the changes to take effect.", + PrimaryButtonText = Resources.Action_Restart, + DefaultButton = ContentDialogButton.Primary, + IsSecondaryButtonEnabled = false, + }; + await restartDialog.ShowAsync(); + + Process.Start(Compat.AppCurrentPath); + App.Shutdown(); + } + } + + #endregion + + #region Debug Section + public void LoadDebugInfo() + { + var assembly = Assembly.GetExecutingAssembly(); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + DebugPaths = $""" + Current Working Directory [Environment.CurrentDirectory] + "{Environment.CurrentDirectory}" + App Directory [Assembly.GetExecutingAssembly().Location] + "{assembly.Location}" + App Directory [AppContext.BaseDirectory] + "{AppContext.BaseDirectory}" + AppData Directory [SpecialFolder.ApplicationData] + "{appData}" + """; + + // 1. Check portable mode + var appDir = Compat.AppCurrentDir; + var expectedPortableFile = Path.Combine(appDir, "Data", ".sm-portable"); + var isPortableMode = File.Exists(expectedPortableFile); + + DebugCompatInfo = $""" + Platform: {Compat.Platform} + AppData: {Compat.AppData} + AppDataHome: {Compat.AppDataHome} + AppCurrentDir: {Compat.AppCurrentDir} + ExecutableName: {Compat.GetExecutableName()} + -- Settings -- + Expected Portable Marker file: {expectedPortableFile} + Portable Marker file exists: {isPortableMode} + IsLibraryDirSet = {settingsManager.IsLibraryDirSet} + IsPortableMode = {settingsManager.IsPortableMode} + """; + + // Get Gpu info + var gpuInfo = ""; + foreach (var (i, gpu) in HardwareHelper.IterGpuInfo().Enumerate()) + { + gpuInfo += $"[{i + 1}] {gpu}\n"; + } + DebugGpuInfo = gpuInfo; + } + + // Debug buttons + [RelayCommand] + private void DebugNotification() + { + notificationService.Show( + new Notification( + title: "Test Notification", + message: "Here is some message", + type: NotificationType.Information + ) + ); + } + + [RelayCommand] + private async Task DebugContentDialog() + { + var dialog = new ContentDialog + { + DefaultButton = ContentDialogButton.Primary, + Title = "Test title", + PrimaryButtonText = Resources.Action_OK, + CloseButtonText = Resources.Action_Close + }; + + var result = await dialog.ShowAsync(); + notificationService.Show(new Notification("Content dialog closed", $"Result: {result}")); + } + + [RelayCommand] + private void DebugThrowException() + { + throw new OperationCanceledException("Example Message"); + } + + [RelayCommand(FlowExceptionsToTaskScheduler = true)] + private async Task DebugThrowAsyncException() + { + await Task.Yield(); + + throw new ApplicationException("Example Message"); + } + + [RelayCommand] + private async Task DebugMakeImageGrid() + { + var provider = App.StorageProvider; + var files = await provider.OpenFilePickerAsync( + new FilePickerOpenOptions() { AllowMultiple = true } + ); + + if (files.Count == 0) + return; + + var images = await files.SelectAsync( + async f => SKImage.FromEncodedData(await f.OpenReadAsync()) + ); + + var grid = ImageProcessor.CreateImageGrid(images.ToImmutableArray()); + + // Show preview + + using var peekPixels = grid.PeekPixels(); + using var data = peekPixels.Encode(SKEncodedImageFormat.Jpeg, 100); + await using var stream = data.AsStream(); + + var bitmap = WriteableBitmap.Decode(stream); + + var galleryImages = new List { new(bitmap), }; + galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString()))); + + var imageBox = new ImageGalleryCard + { + Width = 1000, + Height = 900, + DataContext = dialogFactory.Get(vm => + { + vm.ImageSources.AddRange(galleryImages); + }) + }; + + var dialog = new BetterContentDialog + { + MaxDialogWidth = 1000, + MaxDialogHeight = 1000, + FullSizeDesired = true, + Content = imageBox, + CloseButtonText = "Close", + ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled, + }; + + await dialog.ShowAsync(); + } + + [RelayCommand] + private async Task DebugLoadCompletionCsv() + { + var provider = App.StorageProvider; + var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions()); + + if (files.Count == 0) + return; + + await completionProvider.LoadFromFile(files[0].TryGetLocalPath()!, true); + + notificationService.Show("Loaded completion file", ""); + } + + [RelayCommand] + private async Task DebugImageMetadata() + { + var provider = App.StorageProvider; + var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions()); + + if (files.Count == 0) + return; + + var metadata = ImageMetadata.ParseFile(files[0].TryGetLocalPath()!); + var textualTags = metadata.GetTextualData()?.ToArray(); + + if (textualTags is null) + { + notificationService.Show("No textual data found", ""); + return; + } + + if (metadata.GetGenerationParameters() is { } parameters) + { + var parametersJson = JsonSerializer.Serialize(parameters); + var dialog = DialogHelper.CreateJsonDialog(parametersJson, "Generation Parameters"); + await dialog.ShowAsync(); + } + } + + [RelayCommand] + private async Task DebugRefreshModelsIndex() + { + await modelIndexService.RefreshIndex(); + } + + [RelayCommand] + private async Task DebugTrackedDownload() + { + var textFields = new TextBoxField[] + { + new() { Label = "Url", }, + new() { Label = "File path" } + }; + + var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields); + + if (await dialog.ShowAsync() == ContentDialogResult.Primary) + { + var url = textFields[0].Text; + var filePath = textFields[1].Text; + var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath)); + download.Start(); + } + } + #endregion + + #region Info Section + + public void OnVersionClick() + { + // Ignore if already enabled + if (SharedState.IsDebugMode) + return; + + VersionTapCount++; + + switch (VersionTapCount) + { + // Reached required threshold + case >= VersionTapCountThreshold: + { + IsVersionTapTeachingTipOpen = false; + // Enable debug options + SharedState.IsDebugMode = true; + notificationService.Show( + "Debug options enabled", + "Warning: Improper use may corrupt application state or cause loss of data." + ); + VersionTapCount = 0; + break; + } + // Open teaching tip above 3rd click + case >= 3: + IsVersionTapTeachingTipOpen = true; + break; + } + } + + [RelayCommand] + private async Task ShowLicensesDialog() + { + try + { + var markdown = GetLicensesMarkdown(); + + var dialog = DialogHelper.CreateMarkdownDialog(markdown, "Licenses"); + dialog.MaxDialogHeight = 600; + await dialog.ShowAsync(); + } + catch (Exception e) + { + notificationService.Show( + "Failed to read licenses information", + $"{e}", + NotificationType.Error + ); + } + } + + private static string GetLicensesMarkdown() + { + // Read licenses.json + using var reader = new StreamReader(Assets.LicensesJson.Open()); + var licenses = + JsonSerializer.Deserialize>(reader.ReadToEnd()) + ?? throw new InvalidOperationException("Failed to read licenses.json"); + + // Generate markdown + var builder = new StringBuilder(); + foreach (var license in licenses) + { + builder.AppendLine( + $"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}" + ); + builder.AppendLine(); + builder.AppendLine(license.Description); + builder.AppendLine(); + builder.AppendLine($"[{license.LicenseUrl}]({license.LicenseUrl})"); + builder.AppendLine(); + } + + return builder.ToString(); + } + + #endregion +} diff --git a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs index 74dc145b..948960d3 100644 --- a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs @@ -1,51 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; +using System.Collections.Generic; using System.Collections.ObjectModel; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using System.Diagnostics; -using System.Globalization; -using System.IO; using System.Linq; -using System.Reactive.Linq; -using System.Reflection; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls.Notifications; -using Avalonia.Controls.Primitives; -using Avalonia.Media.Imaging; -using Avalonia.Platform.Storage; -using Avalonia.Styling; -using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; +using DynamicData; using DynamicData.Binding; using FluentAvalonia.UI.Controls; using NLog; -using SkiaSharp; -using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.Extensions; -using StabilityMatrix.Avalonia.Helpers; -using StabilityMatrix.Avalonia.Languages; -using StabilityMatrix.Avalonia.Models; -using StabilityMatrix.Avalonia.Models.Inference; -using StabilityMatrix.Avalonia.Models.TagCompletion; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; -using StabilityMatrix.Avalonia.ViewModels.Dialogs; -using StabilityMatrix.Avalonia.ViewModels.Inference; +using StabilityMatrix.Avalonia.ViewModels.Settings; using StabilityMatrix.Avalonia.Views; -using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Core.Attributes; -using StabilityMatrix.Core.Extensions; -using StabilityMatrix.Core.Helper; -using StabilityMatrix.Core.Models; -using StabilityMatrix.Core.Models.FileInterfaces; -using StabilityMatrix.Core.Python; -using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; @@ -57,875 +22,47 @@ public partial class SettingsViewModel : PageViewModelBase { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - private readonly INotificationService notificationService; - private readonly ISettingsManager settingsManager; - private readonly IPrerequisiteHelper prerequisiteHelper; - private readonly IPyRunner pyRunner; - private readonly ServiceManager dialogFactory; - private readonly ICompletionProvider completionProvider; - private readonly ITrackedDownloadService trackedDownloadService; - private readonly IModelIndexService modelIndexService; - - public SharedState SharedState { get; } - public override string Title => "Settings"; public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; - // ReSharper disable once MemberCanBeMadeStatic.Global - public string AppVersion => - $"Version {Compat.AppVersion}" + (Program.IsDebugBuild ? " (Debug)" : ""); - - // Theme section - [ObservableProperty] - private string? selectedTheme; - - public IReadOnlyList AvailableThemes { get; } = new[] { "Light", "Dark", "System", }; - - [ObservableProperty] - private CultureInfo selectedLanguage; - - // ReSharper disable once MemberCanBeMadeStatic.Global - public IReadOnlyList AvailableLanguages => Cultures.SupportedCultures; - - public IReadOnlyList AnimationScaleOptions { get; } = - new[] { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, }; - - [ObservableProperty] - private float selectedAnimationScale; - - // Shared folder options - [ObservableProperty] - private bool removeSymlinksOnShutdown; - - // Inference UI section - [ObservableProperty] - private bool isPromptCompletionEnabled = true; - - [ObservableProperty] - private IReadOnlyList availableTagCompletionCsvs = Array.Empty(); + public IReadOnlyList SubPages { get; } [ObservableProperty] - private string? selectedTagCompletionCsv; + private ObservableCollection currentPagePath = new(); [ObservableProperty] - private bool isCompletionRemoveUnderscoresEnabled = true; - - [ObservableProperty] - [CustomValidation(typeof(SettingsViewModel), nameof(ValidateOutputImageFileNameFormat))] - private string? outputImageFileNameFormat; - - [ObservableProperty] - private string? outputImageFileNameFormatSample; - - public IEnumerable OutputImageFileNameFormatVars => - FileNameFormatProvider - .GetSample() - .Substitutions.Select( - kv => - new FileNameFormatVar - { - Variable = $"{{{kv.Key}}}", - Example = kv.Value.Invoke() - } - ); - - [ObservableProperty] - private bool isImageViewerPixelGridEnabled = true; - - // Integrations section - [ObservableProperty] - private bool isDiscordRichPresenceEnabled; - - // Debug section - [ObservableProperty] - private string? debugPaths; - - [ObservableProperty] - private string? debugCompatInfo; - - [ObservableProperty] - private string? debugGpuInfo; - - // Info section - private const int VersionTapCountThreshold = 7; - - [ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))] - private int versionTapCount; - - [ObservableProperty] - private bool isVersionTapTeachingTipOpen; - public string VersionFlyoutText => - $"You are {VersionTapCountThreshold - VersionTapCount} clicks away from enabling Debug options."; - - public string DataDirectory => - settingsManager.IsLibraryDirSet ? settingsManager.LibraryDir : "Not set"; - - public SettingsViewModel( - INotificationService notificationService, - ISettingsManager settingsManager, - IPrerequisiteHelper prerequisiteHelper, - IPyRunner pyRunner, - ServiceManager dialogFactory, - ITrackedDownloadService trackedDownloadService, - SharedState sharedState, - ICompletionProvider completionProvider, - IModelIndexService modelIndexService - ) - { - this.notificationService = notificationService; - this.settingsManager = settingsManager; - this.prerequisiteHelper = prerequisiteHelper; - this.pyRunner = pyRunner; - this.dialogFactory = dialogFactory; - this.trackedDownloadService = trackedDownloadService; - this.completionProvider = completionProvider; - this.modelIndexService = modelIndexService; - - SharedState = sharedState; - - SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1]; - SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language); - RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown; - SelectedAnimationScale = settingsManager.Settings.AnimationScale; - - settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme); - - settingsManager.RelayPropertyFor( - this, - vm => vm.IsDiscordRichPresenceEnabled, - settings => settings.IsDiscordRichPresenceEnabled, - true - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.SelectedAnimationScale, - settings => settings.AnimationScale - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.SelectedTagCompletionCsv, - settings => settings.TagCompletionCsv - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.IsPromptCompletionEnabled, - settings => settings.IsPromptCompletionEnabled, - true - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.IsCompletionRemoveUnderscoresEnabled, - settings => settings.IsCompletionRemoveUnderscoresEnabled, - true - ); - - this.WhenPropertyChanged(vm => vm.OutputImageFileNameFormat) - .Throttle(TimeSpan.FromMilliseconds(50)) - .Subscribe(formatProperty => - { - var provider = FileNameFormatProvider.GetSample(); - var template = formatProperty.Value ?? string.Empty; - - if ( - !string.IsNullOrEmpty(template) - && provider.Validate(template) == ValidationResult.Success - ) - { - var format = FileNameFormat.Parse(template, provider); - OutputImageFileNameFormatSample = format.GetFileName() + ".png"; - } - else - { - // Use default format if empty - var defaultFormat = FileNameFormat.Parse( - FileNameFormat.DefaultTemplate, - provider - ); - OutputImageFileNameFormatSample = defaultFormat.GetFileName() + ".png"; - } - }); - - settingsManager.RelayPropertyFor( - this, - vm => vm.OutputImageFileNameFormat, - settings => settings.InferenceOutputImageFileNameFormat, - true - ); - - settingsManager.RelayPropertyFor( - this, - vm => vm.IsImageViewerPixelGridEnabled, - settings => settings.IsImageViewerPixelGridEnabled, - true - ); - - DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler( - notificationService, - LogLevel.Warn - ); - ImportTagCsvCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn); - } - - /// - public override async Task OnLoadedAsync() - { - await base.OnLoadedAsync(); - - await notificationService.TryAsync(completionProvider.Setup()); - - UpdateAvailableTagCompletionCsvs(); - } - - public static ValidationResult ValidateOutputImageFileNameFormat( - string? format, - ValidationContext context - ) - { - return FileNameFormatProvider.GetSample().Validate(format ?? string.Empty); - } - - partial void OnSelectedThemeChanged(string? value) - { - // In case design / tests - if (Application.Current is null) - return; - // Change theme - Application.Current.RequestedThemeVariant = value switch - { - "Dark" => ThemeVariant.Dark, - "Light" => ThemeVariant.Light, - _ => ThemeVariant.Default - }; - } - - partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newValue) - { - if (oldValue is null || newValue.Name == Cultures.Current?.Name) - return; - - // Set locale - if (AvailableLanguages.Contains(newValue)) - { - Logger.Info("Changing language from {Old} to {New}", oldValue, newValue); - - Cultures.TrySetSupportedCulture(newValue); - settingsManager.Transaction(s => s.Language = newValue.Name); - - var dialog = new BetterContentDialog - { - Title = Resources.Label_RelaunchRequired, - Content = Resources.Text_RelaunchRequiredToApplyLanguage, - DefaultButton = ContentDialogButton.Primary, - PrimaryButtonText = Resources.Action_Relaunch, - CloseButtonText = Resources.Action_RelaunchLater - }; - - Dispatcher.UIThread.InvokeAsync(async () => - { - if (await dialog.ShowAsync() == ContentDialogResult.Primary) - { - Process.Start(Compat.AppCurrentPath); - App.Shutdown(); - } - }); - } - else - { - Logger.Info( - "Requested invalid language change from {Old} to {New}", - oldValue, - newValue - ); - } - } - - partial void OnRemoveSymlinksOnShutdownChanged(bool value) - { - settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value); - } - - public async Task ResetCheckpointCache() - { - settingsManager.Transaction(s => s.InstalledModelHashes = new HashSet()); - await Task.Run(() => settingsManager.IndexCheckpoints()); - notificationService.Show( - "Checkpoint cache reset", - "The checkpoint cache has been reset.", - NotificationType.Success - ); - } - - #region Package Environment - - [RelayCommand] - private async Task OpenEnvVarsDialog() - { - var viewModel = dialogFactory.Get(); - - // Load current settings - var current = - settingsManager.Settings.EnvironmentVariables ?? new Dictionary(); - viewModel.EnvVars = new ObservableCollection( - current.Select(kvp => new EnvVarKeyPair(kvp.Key, kvp.Value)) - ); - - var dialog = new BetterContentDialog - { - Content = new EnvVarsDialog { DataContext = viewModel }, - PrimaryButtonText = Resources.Action_Save, - IsPrimaryButtonEnabled = true, - CloseButtonText = Resources.Action_Cancel, - }; - - if (await dialog.ShowAsync() == ContentDialogResult.Primary) - { - // Save settings - var newEnvVars = viewModel.EnvVars - .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) - .GroupBy(kvp => kvp.Key, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal); - settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars); - } - } - - [RelayCommand] - private async Task CheckPythonVersion() - { - var isInstalled = prerequisiteHelper.IsPythonInstalled; - Logger.Debug($"Check python installed: {isInstalled}"); - // Ensure python installed - if (!prerequisiteHelper.IsPythonInstalled) - { - // Need 7z as well for site packages repack - Logger.Debug("Python not installed, unpacking resources..."); - await prerequisiteHelper.UnpackResourcesIfNecessary(); - Logger.Debug("Unpacked resources, installing python..."); - await prerequisiteHelper.InstallPythonIfNecessary(); - } - - // Get python version - await pyRunner.Initialize(); - var result = await pyRunner.GetVersionInfo(); - // Show dialog box - var dialog = new ContentDialog - { - Title = Resources.Label_PythonVersionInfo, - Content = result, - PrimaryButtonText = Resources.Action_OK, - IsPrimaryButtonEnabled = true - }; - await dialog.ShowAsync(); - } - - #endregion - - #region Inference UI - - private void UpdateAvailableTagCompletionCsvs() - { - if (!settingsManager.IsLibraryDirSet) - return; - - var tagsDir = settingsManager.TagsDirectory; - if (!tagsDir.Exists) - return; - - var csvFiles = tagsDir.Info.EnumerateFiles("*.csv"); - AvailableTagCompletionCsvs = csvFiles.Select(f => f.Name).ToImmutableArray(); - - // Set selected to current if exists - var settingsCsv = settingsManager.Settings.TagCompletionCsv; - if (settingsCsv is not null && AvailableTagCompletionCsvs.Contains(settingsCsv)) - { - SelectedTagCompletionCsv = settingsCsv; - } - } - - [RelayCommand(FlowExceptionsToTaskScheduler = true)] - private async Task ImportTagCsv() - { - var storage = App.StorageProvider; - var files = await storage.OpenFilePickerAsync( - new FilePickerOpenOptions - { - FileTypeFilter = new List - { - new("CSV") { Patterns = new[] { "*.csv" }, } - } - } - ); - - if (files.Count == 0) - return; - - var sourceFile = new FilePath(files[0].TryGetLocalPath()!); - - var tagsDir = settingsManager.TagsDirectory; - tagsDir.Create(); - - // Copy to tags directory - var targetFile = tagsDir.JoinFile(sourceFile.Name); - await sourceFile.CopyToAsync(targetFile); - - // Update index - UpdateAvailableTagCompletionCsvs(); - - // Trigger load - completionProvider.BackgroundLoadFromFile(targetFile, true); - - notificationService.Show( - $"Imported {sourceFile.Name}", - $"The {sourceFile.Name} file has been imported.", - NotificationType.Success - ); - } - #endregion - - #region System + private PageViewModelBase? currentPage; - /// - /// Adds Stability Matrix to Start Menu for the current user. - /// - [RelayCommand] - private async Task AddToStartMenu() + public SettingsViewModel(ServiceManager vmFactory) { - if (!Compat.IsWindows) + SubPages = new PageViewModelBase[] { - notificationService.Show("Not supported", "This feature is only supported on Windows."); - return; - } - - await using var _ = new MinimumDelay(200, 300); - - var shortcutDir = new DirectoryPath( - Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), - "Programs" - ); - var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk"); - - var appPath = Compat.AppCurrentPath; - var iconPath = shortcutDir.JoinFile("Stability Matrix.ico"); - await Assets.AppIcon.ExtractTo(iconPath); - - WindowsShortcuts.CreateShortcut(shortcutLink, appPath, iconPath, "Stability Matrix"); - - notificationService.Show( - "Added to Start Menu", - "Stability Matrix has been added to the Start Menu.", - NotificationType.Success - ); - } - - /// - /// Add Stability Matrix to Start Menu for all users. - /// Requires Admin elevation. - /// - [RelayCommand] - private async Task AddToGlobalStartMenu() - { - if (!Compat.IsWindows) - { - notificationService.Show("Not supported", "This feature is only supported on Windows."); - return; - } - - // Confirmation dialog - var dialog = new BetterContentDialog - { - Title = - "This will create a shortcut for Stability Matrix in the Start Menu for all users", - Content = "You will be prompted for administrator privileges. Continue?", - PrimaryButtonText = Resources.Action_Yes, - CloseButtonText = Resources.Action_Cancel, - DefaultButton = ContentDialogButton.Primary + vmFactory.Get(), + vmFactory.Get(), }; - if (await dialog.ShowAsync() != ContentDialogResult.Primary) - { - return; - } - - await using var _ = new MinimumDelay(200, 300); - - var shortcutDir = new DirectoryPath( - Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), - "Programs" - ); - var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk"); - - var appPath = Compat.AppCurrentPath; - var iconPath = shortcutDir.JoinFile("Stability Matrix.ico"); - - // We can't directly write to the targets, so extract to temporary directory first - using var tempDir = new TempDirectoryPath(); - - await Assets.AppIcon.ExtractTo(tempDir.JoinFile("Stability Matrix.ico")); - WindowsShortcuts.CreateShortcut( - tempDir.JoinFile("Stability Matrix.lnk"), - appPath, - iconPath, - "Stability Matrix" - ); - - // Move to target - try - { - var moveLinkResult = await WindowsElevated.MoveFiles( - (tempDir.JoinFile("Stability Matrix.lnk"), shortcutLink), - (tempDir.JoinFile("Stability Matrix.ico"), iconPath) - ); - if (moveLinkResult != 0) - { - notificationService.ShowPersistent( - "Failed to create shortcut", - $"Could not copy shortcut", - NotificationType.Error - ); - } - } - catch (Win32Exception e) - { - // We'll get this exception if user cancels UAC - Logger.Warn(e, "Could not create shortcut"); - notificationService.Show("Could not create shortcut", "", NotificationType.Warning); - return; - } + CurrentPagePath.AddRange(SubPages); - notificationService.Show( - "Added to Start Menu", - "Stability Matrix has been added to the Start Menu for all users.", - NotificationType.Success - ); + CurrentPage = SubPages[0]; } - public async Task PickNewDataDirectory() + partial void OnCurrentPageChanged(PageViewModelBase? value) { - var viewModel = dialogFactory.Get(); - var dialog = new BetterContentDialog + if (value is null) { - IsPrimaryButtonEnabled = false, - IsSecondaryButtonEnabled = false, - IsFooterVisible = false, - Content = new SelectDataDirectoryDialog { DataContext = viewModel } - }; - - var result = await dialog.ShowAsync(); - if (result == ContentDialogResult.Primary) - { - // 1. For portable mode, call settings.SetPortableMode() - if (viewModel.IsPortableMode) - { - settingsManager.SetPortableMode(); - } - // 2. For custom path, call settings.SetLibraryPath(path) - else - { - settingsManager.SetLibraryPath(viewModel.DataDirectory); - } - - // Restart - var restartDialog = new BetterContentDialog - { - Title = "Restart required", - Content = "Stability Matrix must be restarted for the changes to take effect.", - PrimaryButtonText = Resources.Action_Restart, - DefaultButton = ContentDialogButton.Primary, - IsSecondaryButtonEnabled = false, - }; - await restartDialog.ShowAsync(); - - Process.Start(Compat.AppCurrentPath); - App.Shutdown(); - } - } - - #endregion - - #region Debug Section - public void LoadDebugInfo() - { - var assembly = Assembly.GetExecutingAssembly(); - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - DebugPaths = $""" - Current Working Directory [Environment.CurrentDirectory] - "{Environment.CurrentDirectory}" - App Directory [Assembly.GetExecutingAssembly().Location] - "{assembly.Location}" - App Directory [AppContext.BaseDirectory] - "{AppContext.BaseDirectory}" - AppData Directory [SpecialFolder.ApplicationData] - "{appData}" - """; - - // 1. Check portable mode - var appDir = Compat.AppCurrentDir; - var expectedPortableFile = Path.Combine(appDir, "Data", ".sm-portable"); - var isPortableMode = File.Exists(expectedPortableFile); - - DebugCompatInfo = $""" - Platform: {Compat.Platform} - AppData: {Compat.AppData} - AppDataHome: {Compat.AppDataHome} - AppCurrentDir: {Compat.AppCurrentDir} - ExecutableName: {Compat.GetExecutableName()} - -- Settings -- - Expected Portable Marker file: {expectedPortableFile} - Portable Marker file exists: {isPortableMode} - IsLibraryDirSet = {settingsManager.IsLibraryDirSet} - IsPortableMode = {settingsManager.IsPortableMode} - """; - - // Get Gpu info - var gpuInfo = ""; - foreach (var (i, gpu) in HardwareHelper.IterGpuInfo().Enumerate()) - { - gpuInfo += $"[{i + 1}] {gpu}\n"; - } - DebugGpuInfo = gpuInfo; - } - - // Debug buttons - [RelayCommand] - private void DebugNotification() - { - notificationService.Show( - new Notification( - title: "Test Notification", - message: "Here is some message", - type: NotificationType.Information - ) - ); - } - - [RelayCommand] - private async Task DebugContentDialog() - { - var dialog = new ContentDialog - { - DefaultButton = ContentDialogButton.Primary, - Title = "Test title", - PrimaryButtonText = Resources.Action_OK, - CloseButtonText = Resources.Action_Close - }; - - var result = await dialog.ShowAsync(); - notificationService.Show(new Notification("Content dialog closed", $"Result: {result}")); - } - - [RelayCommand] - private void DebugThrowException() - { - throw new OperationCanceledException("Example Message"); - } - - [RelayCommand(FlowExceptionsToTaskScheduler = true)] - private async Task DebugThrowAsyncException() - { - await Task.Yield(); - - throw new ApplicationException("Example Message"); - } - - [RelayCommand] - private async Task DebugMakeImageGrid() - { - var provider = App.StorageProvider; - var files = await provider.OpenFilePickerAsync( - new FilePickerOpenOptions() { AllowMultiple = true } - ); - - if (files.Count == 0) return; - - var images = await files.SelectAsync( - async f => SKImage.FromEncodedData(await f.OpenReadAsync()) - ); - - var grid = ImageProcessor.CreateImageGrid(images.ToImmutableArray()); - - // Show preview - - using var peekPixels = grid.PeekPixels(); - using var data = peekPixels.Encode(SKEncodedImageFormat.Jpeg, 100); - await using var stream = data.AsStream(); - - var bitmap = WriteableBitmap.Decode(stream); - - var galleryImages = new List { new(bitmap), }; - galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString()))); - - var imageBox = new ImageGalleryCard - { - Width = 1000, - Height = 900, - DataContext = dialogFactory.Get(vm => - { - vm.ImageSources.AddRange(galleryImages); - }) - }; - - var dialog = new BetterContentDialog - { - MaxDialogWidth = 1000, - MaxDialogHeight = 1000, - FullSizeDesired = true, - Content = imageBox, - CloseButtonText = "Close", - ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled, - }; - - await dialog.ShowAsync(); - } - - [RelayCommand] - private async Task DebugLoadCompletionCsv() - { - var provider = App.StorageProvider; - var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions()); - - if (files.Count == 0) - return; - - await completionProvider.LoadFromFile(files[0].TryGetLocalPath()!, true); - - notificationService.Show("Loaded completion file", ""); - } - - [RelayCommand] - private async Task DebugImageMetadata() - { - var provider = App.StorageProvider; - var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions()); - - if (files.Count == 0) - return; - - var metadata = ImageMetadata.ParseFile(files[0].TryGetLocalPath()!); - var textualTags = metadata.GetTextualData()?.ToArray(); - - if (textualTags is null) - { - notificationService.Show("No textual data found", ""); - return; - } - - if (metadata.GetGenerationParameters() is { } parameters) - { - var parametersJson = JsonSerializer.Serialize(parameters); - var dialog = DialogHelper.CreateJsonDialog(parametersJson, "Generation Parameters"); - await dialog.ShowAsync(); - } - } - - [RelayCommand] - private async Task DebugRefreshModelsIndex() - { - await modelIndexService.RefreshIndex(); - } - - [RelayCommand] - private async Task DebugTrackedDownload() - { - var textFields = new TextBoxField[] - { - new() { Label = "Url", }, - new() { Label = "File path" } - }; - - var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields); - - if (await dialog.ShowAsync() == ContentDialogResult.Primary) - { - var url = textFields[0].Text; - var filePath = textFields[1].Text; - var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath)); - download.Start(); - } - } - #endregion - - #region Info Section - - public void OnVersionClick() - { - // Ignore if already enabled - if (SharedState.IsDebugMode) - return; - - VersionTapCount++; - - switch (VersionTapCount) - { - // Reached required threshold - case >= VersionTapCountThreshold: - { - IsVersionTapTeachingTipOpen = false; - // Enable debug options - SharedState.IsDebugMode = true; - notificationService.Show( - "Debug options enabled", - "Warning: Improper use may corrupt application state or cause loss of data." - ); - VersionTapCount = 0; - break; - } - // Open teaching tip above 3rd click - case >= 3: - IsVersionTapTeachingTipOpen = true; - break; } - } - - [RelayCommand] - private async Task ShowLicensesDialog() - { - try - { - var markdown = GetLicensesMarkdown(); - var dialog = DialogHelper.CreateMarkdownDialog(markdown, "Licenses"); - dialog.MaxDialogHeight = 600; - await dialog.ShowAsync(); - } - catch (Exception e) + if (value is MainSettingsViewModel) { - notificationService.Show( - "Failed to read licenses information", - $"{e}", - NotificationType.Error - ); + CurrentPagePath.Clear(); + CurrentPagePath.Add(value); } - } - - private static string GetLicensesMarkdown() - { - // Read licenses.json - using var reader = new StreamReader(Assets.LicensesJson.Open()); - var licenses = - JsonSerializer.Deserialize>(reader.ReadToEnd()) - ?? throw new InvalidOperationException("Failed to read licenses.json"); - - // Generate markdown - var builder = new StringBuilder(); - foreach (var license in licenses) + else { - builder.AppendLine( - $"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}" - ); - builder.AppendLine(); - builder.AppendLine(license.Description); - builder.AppendLine(); - builder.AppendLine($"[{license.LicenseUrl}]({license.LicenseUrl})"); - builder.AppendLine(); + CurrentPagePath.Clear(); + CurrentPagePath.AddRange(new[] { SubPages[0], value }); } - - return builder.ToString(); } - - #endregion } diff --git a/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs b/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs index 57fc1c04..ab02c19d 100644 --- a/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; using AsyncImageLoader; using Avalonia; using Avalonia.Controls; @@ -24,8 +25,10 @@ using Microsoft.Extensions.DependencyInjection; using NLog; using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Diagnostics.LogViewer.Core.ViewModels; using StabilityMatrix.Avalonia.Extensions; using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels.Base; @@ -44,7 +47,7 @@ namespace StabilityMatrix.Avalonia.Views; public partial class MainWindow : AppWindowBase { private readonly INotificationService notificationService; - private readonly INavigationService navigationService; + private readonly INavigationService navigationService; private FlyoutBase? progressFlyout; @@ -58,7 +61,7 @@ public partial class MainWindow : AppWindowBase public MainWindow( INotificationService notificationService, - INavigationService navigationService + INavigationService navigationService ) { this.notificationService = notificationService; @@ -74,6 +77,8 @@ public partial class MainWindow : AppWindowBase TitleBar.ExtendsContentIntoTitleBar = true; TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex; + navigationService.TypedNavigation += NavigationService_OnTypedNavigation; + EventManager.Instance.ToggleProgressFlyout += (_, _) => progressFlyout?.Hide(); EventManager.Instance.CultureChanged += (_, _) => SetDefaultFonts(); EventManager.Instance.UpdateAvailable += OnUpdateAvailable; @@ -158,6 +163,15 @@ public partial class MainWindow : AppWindowBase } } + private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e) + { + var mainViewModel = (MainWindowViewModel)DataContext!; + + mainViewModel.SelectedCategory = mainViewModel.Pages.FirstOrDefault( + x => x.GetType() == e.ViewModelType + ); + } + private void OnUpdateAvailable(object? sender, UpdateInfo? updateInfo) { Dispatcher.UIThread.Post(() => diff --git a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml index 428c0483..a5abbfc3 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml @@ -1,16 +1,69 @@ - - Welcome to Avalonia! + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs index 20cf1dd1..eea11ded 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/Settings/InferenceSettingsPage.axaml.cs @@ -1,19 +1,13 @@ -using Avalonia.Controls; -using Avalonia.Markup.Xaml; +using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Core.Attributes; namespace StabilityMatrix.Avalonia.Views.Settings; [Singleton] -public partial class InferenceSettingsPage : UserControl +public partial class InferenceSettingsPage : UserControlBase { public InferenceSettingsPage() { InitializeComponent(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } diff --git a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml new file mode 100644 index 00000000..60c84b6c --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml @@ -0,0 +1,533 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs index ba3d18c9..aa34012d 100644 --- a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml.cs @@ -1,5 +1,19 @@ -using Avalonia.Markup.Xaml; +using System; +using System.ComponentModel; +using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Media.Animation; +using FluentAvalonia.UI.Navigation; +using Microsoft.Extensions.DependencyInjection; +using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.Models; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels; +using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Core.Attributes; namespace StabilityMatrix.Avalonia.Views; @@ -7,13 +21,77 @@ namespace StabilityMatrix.Avalonia.Views; [Singleton] public partial class SettingsPage : UserControlBase { + private readonly INavigationService settingsNavigationService; + + private SettingsViewModel ViewModel => (SettingsViewModel)DataContext!; + + [DesignOnly(true)] + [Obsolete("For XAML use only", true)] public SettingsPage() + : this(App.Services.GetRequiredService>()) { } + + public SettingsPage(INavigationService settingsNavigationService) { + this.settingsNavigationService = settingsNavigationService; + InitializeComponent(); + + settingsNavigationService.SetFrame(FrameView); + settingsNavigationService.TypedNavigation += NavigationService_OnTypedNavigation; + FrameView.Navigated += FrameView_Navigated; + BreadcrumbBar.ItemClicked += BreadcrumbBar_ItemClicked; + } + + /// + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + Dispatcher.UIThread.Post( + () => + settingsNavigationService.NavigateTo( + ViewModel.SubPages[0], + new BetterSlideNavigationTransition + { + Effect = SlideNavigationTransitionEffect.FromBottom + } + ) + ); } - private void InitializeComponent() + private void NavigationService_OnTypedNavigation(object? sender, TypedNavigationEventArgs e) { - AvaloniaXamlLoader.Load(this); + ViewModel.CurrentPage = ViewModel.SubPages.FirstOrDefault( + x => x.GetType() == e.ViewModelType + ); + } + + private async void FrameView_Navigated(object? sender, NavigationEventArgs args) + { + if (args.Content is not PageViewModelBase vm) + { + return; + } + + ViewModel.CurrentPage = vm; + } + + private async void BreadcrumbBar_ItemClicked( + BreadcrumbBar sender, + BreadcrumbBarItemClickedEventArgs args + ) + { + if (args.Item is not PageViewModelBase viewModel) + { + return; + } + + settingsNavigationService.NavigateTo( + viewModel, + new BetterSlideNavigationTransition + { + Effect = SlideNavigationTransitionEffect.FromLeft + } + ); } } diff --git a/StabilityMatrix.Core/Attributes/SingletonAttribute.cs b/StabilityMatrix.Core/Attributes/SingletonAttribute.cs index d6538a4e..39e1c8cd 100644 --- a/StabilityMatrix.Core/Attributes/SingletonAttribute.cs +++ b/StabilityMatrix.Core/Attributes/SingletonAttribute.cs @@ -3,16 +3,25 @@ namespace StabilityMatrix.Core.Attributes; [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] -[AttributeUsage(AttributeTargets.Class)] +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] public class SingletonAttribute : Attribute { [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] public Type? InterfaceType { get; init; } + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + public Type? ImplType { get; init; } + public SingletonAttribute() { } public SingletonAttribute(Type interfaceType) { InterfaceType = interfaceType; } + + public SingletonAttribute(Type interfaceType, Type implType) + { + InterfaceType = implType; + ImplType = implType; + } }